Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f99dff5238 | |||
| 1936ced1ed | |||
| cb695e653a | |||
| f57949c9a2 | |||
| 94fb3d6159 | |||
| 51560f191f | |||
| c0fcdf3b6e | |||
| 969efd2af7 | |||
| 87fccfee3a | |||
| e643effefa | |||
| c531be8780 | |||
| 6e3cecc57e | |||
| b3d385861f | |||
| cc5a8fa4b3 | |||
| 78ff727064 | |||
| 54dad707a4 | |||
| df8bc742fb | |||
| e342a4f143 | |||
| febb7b1748 | |||
| 537867a64b | |||
| 82a6fe64ae | |||
| 984a66b590 | |||
| bd79ad2a7d | |||
| b583de88f3 | |||
| baccb9e719 | |||
| 0fd11693f3 | |||
| 05f7a34d43 | |||
| 315e72c412 | |||
| f916373f55 | |||
| 5353211206 | |||
| 999ab0df2b | |||
| f8700c84b8 | |||
| ddd916161e | |||
| c3cd5e3109 | |||
| 051441d875 | |||
| 0cf5051cf8 | |||
| 12ca7aa085 | |||
| d53dc4cf28 | |||
| e7298b255a | |||
| ad02f21d04 | |||
| 46323b3fe7 | |||
| 86b3593fec | |||
| d7c504f48e | |||
| 6ad2001c7d | |||
| c5d4c762d8 | |||
| 8b66c2be1d | |||
| cfb22d7ec1 | |||
| f01ff067a5 | |||
| 55a3f9fcc9 | |||
| 996f6f68d2 | |||
| 3b8ac8b16a | |||
| 381f03b56a | |||
| 117d711d7d | |||
| 2f71804ff4 | |||
| 600f19cc87 | |||
| 4a77e937af | |||
| f7e30822c3 | |||
| 1cffb70362 | |||
| 462ba8d7f7 | |||
| 991c54150d | |||
| 6eedf4111f | |||
| af1f966250 | |||
| 7f54b27079 | |||
| a3ce03cc9d | |||
| 502db64b28 | |||
| 033806df4b | |||
| 50bd6eb5d9 | |||
| 3387201634 | |||
| 76acd81fa4 | |||
| 28b235595e | |||
| 9398f7abd7 | |||
| 0f55c81e2b | |||
| f2f60a8f73 | |||
| e777f12490 | |||
| 7bdf133b9d | |||
| 7eb1d15315 | |||
| a74186c555 | |||
| fb2fdf17b7 | |||
| 6edb3da977 | |||
| c0309b6ad1 | |||
| 7bf1dd6e7a | |||
| 6a008eee14 | |||
| b9b2d6822a | |||
| 459284ab96 | |||
| b193bc212a | |||
| 3ebeb38ade | |||
| c951ac2876 | |||
| 7275bc808a | |||
| e40446ad3c | |||
| fba23cf464 | |||
| bae872e79b | |||
| d0c9ef5acf | |||
| 0c74497ef7 | |||
| 369054b02d | |||
| c3c42b0c37 | |||
| 4938a94968 | |||
| ffe1be59ea | |||
| 2318e9011d | |||
| 67df305ac2 | |||
| 2e57e3351b | |||
| a412cde3b3 | |||
| abfd9306a4 | |||
| 8889e44b5d | |||
| 9657bf1f5b | |||
| 8c5f88ac62 | |||
| 736f8b4dac | |||
| 60e1d9e361 | |||
| 7967ce4df2 | |||
| 71c7845c42 | |||
| aa56d23551 | |||
| 3cd448099d | |||
| 47ccefba4e | |||
| 1faccc7eca | |||
| e74d875d95 |
@@ -1,10 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a bug report
|
||||
title: ''
|
||||
labels: bug, triage needed
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
name: "Bug Report"
|
||||
description: "Report a bug or unexpected behavior"
|
||||
title: "[Bug]: "
|
||||
type: "Bug"
|
||||
labels: ["triage needed"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting a bug! Please provide detailed information to help us reproduce and fix the issue.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: "Bug Summary"
|
||||
description: "Briefly describe the bug."
|
||||
placeholder: "e.g., Error when saving a new entry"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: "Description"
|
||||
description: "A clear and concise description of the bug."
|
||||
placeholder: "e.g., When I click 'Save', I get a 500 error."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps_to_reproduce
|
||||
attributes:
|
||||
label: "Steps to Reproduce"
|
||||
description: "Steps to reproduce the behavior."
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected_behavior
|
||||
attributes:
|
||||
label: "Expected Behavior"
|
||||
description: "What should happen instead?"
|
||||
placeholder: "e.g., The form should be saved successfully."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual_behavior
|
||||
attributes:
|
||||
label: "Actual Behavior"
|
||||
description: "What actually happens?"
|
||||
placeholder: "e.g., A 500 error is returned with no useful error message."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: "Version"
|
||||
description: "Which version of Miniflux are you using?"
|
||||
placeholder: "e.g., 2.2.6"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: "Browser"
|
||||
description: "If applicable, which browser are you using? Please provide the version."
|
||||
placeholder: "e.g., Chrome, Firefox, Safari"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: "Relevant Logs or Error Output"
|
||||
description: "Paste any relevant logs or error messages (if applicable)."
|
||||
render: shell
|
||||
placeholder: "e.g., Stack trace, log files, browser console logs, or console output"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional_context
|
||||
attributes:
|
||||
label: "Additional Context"
|
||||
description: "Add any other context about the problem here."
|
||||
placeholder: "e.g., Screenshots, video recordings, or related issues"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: agreement
|
||||
attributes:
|
||||
label: "Checklist"
|
||||
description: "Please confirm the following:"
|
||||
options:
|
||||
- label: "I have searched existing issues to ensure this bug hasn't been reported before."
|
||||
required: true
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: wishlist
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
- [ ] I have read this document: https://miniflux.app/opinionated.html#feature-request
|
||||
@@ -0,0 +1,65 @@
|
||||
name: "Feature Request"
|
||||
description: "Suggest an idea or improvement for the project"
|
||||
title: "[Feature]: "
|
||||
type: "Feature"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to suggest a feature! Please provide detailed information to help us understand and evaluate your idea.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: "Feature Summary"
|
||||
description: "Briefly describe the feature or enhancement."
|
||||
placeholder: "e.g., Add dark mode support"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: "What problem does this feature solve?"
|
||||
description: "Explain the problem or limitation this feature would address."
|
||||
placeholder: "e.g., It's difficult to use the app in low-light environments."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: "Proposed Solution"
|
||||
description: "Describe how you think this feature should work."
|
||||
placeholder: "e.g., Add a toggle in settings to switch between light and dark mode."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: "Alternatives Considered"
|
||||
description: "Have you considered other solutions or workarounds?"
|
||||
placeholder: "e.g., Using browser extensions to force dark mode."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional_context
|
||||
attributes:
|
||||
label: "Additional Context"
|
||||
description: "Add any other context, screenshots, or examples to explain your request."
|
||||
placeholder: "e.g., A screenshot of a similar feature in another project."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: agreement
|
||||
attributes:
|
||||
label: "Checklist"
|
||||
description: "Please confirm the following:"
|
||||
options:
|
||||
- label: "I have searched existing issues to ensure this feature hasn't been requested before."
|
||||
required: true
|
||||
- label: "I understand that feature requests are not guaranteed to be implemented."
|
||||
required: true
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
name: Feed Problems
|
||||
about: Problems with a feed or a website
|
||||
title: ''
|
||||
labels: feed problems, triage needed
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
name: "Feed/Website Issue"
|
||||
description: "Report problems with a specific feed or website"
|
||||
title: "[Feed Issue]: "
|
||||
type: "Feed Issue"
|
||||
labels: ["triage needed"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting an issue with a feed or website! Please provide detailed information to help us diagnose and resolve the problem.
|
||||
|
||||
- type: input
|
||||
id: feed_url
|
||||
attributes:
|
||||
label: "Feed URL"
|
||||
description: "Provide the URL of the feed that is not working correctly."
|
||||
placeholder: "e.g., https://example.com/feed.xml"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: website_url
|
||||
attributes:
|
||||
label: "Website URL"
|
||||
description: "Provide the URL of the website."
|
||||
placeholder: "e.g., https://example.com"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem_description
|
||||
attributes:
|
||||
label: "Problem Description"
|
||||
description: "Describe the issue you are experiencing with this feed."
|
||||
placeholder: |
|
||||
e.g.,
|
||||
- The feed URL returns a 403 error.
|
||||
- The content is malformed.
|
||||
- Images are not loading in the web ui.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected_behavior
|
||||
attributes:
|
||||
label: "Expected Behavior"
|
||||
description: "Describe what you expect to happen."
|
||||
placeholder: "e.g., The feed should show the images correctly."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: error_logs
|
||||
attributes:
|
||||
label: "Relevant Logs or Error Output"
|
||||
description: "Paste any relevant logs or error messages, if available."
|
||||
render: shell
|
||||
placeholder: "e.g., HTTP error codes, invalid XML warnings, etc."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional_context
|
||||
attributes:
|
||||
label: "Additional Context"
|
||||
description: "Add any other context, screenshots, or related information to help us troubleshoot."
|
||||
placeholder: "e.g., Is this a recurring problem? Did the feed work before?"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: troubleshooting
|
||||
attributes:
|
||||
label: "Troubleshooting Steps"
|
||||
description: "Please confirm that you have tried the following:"
|
||||
options:
|
||||
- label: "I have checked if the feed URL is correct and accessible in a web browser."
|
||||
required: true
|
||||
- label: "I have checked if the feed URL is correct and accessible with `curl`."
|
||||
required: true
|
||||
- label: "I have verified that the feed is valid using an RSS/Atom validator."
|
||||
required: false
|
||||
- label: "I have searched for existing issues to avoid duplicates."
|
||||
required: true
|
||||
@@ -0,0 +1,86 @@
|
||||
name: "Proposal / RFC"
|
||||
description: "Propose a significant change, or architectural decision"
|
||||
title: "[Proposal]: "
|
||||
type: "Proposal"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to submit a proposal! Please provide detailed information to ensure a productive discussion.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: "Proposal Summary"
|
||||
description: "A brief summary of the proposed change or idea."
|
||||
placeholder: "e.g., Refactor database schema for performance optimization"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: "Motivation and Context"
|
||||
description: "Explain the problem this proposal addresses. Why is it necessary? What are the current limitations or pain points?"
|
||||
placeholder: |
|
||||
e.g.,
|
||||
- The current database schema causes performance bottlenecks when querying large datasets.
|
||||
- Adding this feature will improve scalability and reliability for large-scale use cases.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed_solution
|
||||
attributes:
|
||||
label: "Proposed Solution"
|
||||
description: "Describe the proposed solution or approach. Include technical details, diagrams, and examples where possible."
|
||||
placeholder: |
|
||||
e.g.,
|
||||
- Redesign the schema to normalize tables and introduce indexing.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: "Alternatives Considered"
|
||||
description: "List any alternative approaches that were considered and explain why they were rejected."
|
||||
placeholder: |
|
||||
e.g.,
|
||||
- Use Redis for caching, but it adds operational complexity.
|
||||
- Stick with the current schema and optimize queries, but this has limited impact on performance.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: "Impact and Risks"
|
||||
description: "Describe the potential impact of this change. Highlight possible risks and backward compatibility concerns."
|
||||
placeholder: |
|
||||
e.g.,
|
||||
- May require data migration with downtime.
|
||||
- Could introduce breaking changes in API responses.
|
||||
- Affects core functionality, requiring extensive testing.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional_context
|
||||
attributes:
|
||||
label: "Additional Context or References"
|
||||
description: "Add any relevant context, links to related discussions, RFCs, or design documents."
|
||||
placeholder: "e.g., Links to research, GitHub issues, or similar projects"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: agreement
|
||||
attributes:
|
||||
label: "Checklist"
|
||||
description: "Please confirm the following:"
|
||||
options:
|
||||
- label: "I have reviewed existing proposals to ensure this change hasn't been proposed before."
|
||||
required: true
|
||||
- label: "I agree to provide follow-up updates and maintain discussion on this proposal."
|
||||
required: true
|
||||
@@ -1,7 +1,7 @@
|
||||
Do you follow the guidelines?
|
||||
Have you followed these guidelines?
|
||||
|
||||
- [ ] I have tested my changes
|
||||
- [ ] There are no breaking changes
|
||||
- [ ] I really tested my changes and there is no regression
|
||||
- [ ] Ideally, my commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
|
||||
- [ ] I read this document: https://miniflux.app/faq.html#pull-request
|
||||
- [ ] I have thoroughly tested my changes and verified there are no regressions
|
||||
- [ ] My commit messages follow the [Conventional Commits specification](https://www.conventionalcommits.org/)
|
||||
- [ ] I have read this document: https://miniflux.app/faq.html#pull-request
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
go-version: "1.24.x"
|
||||
check-latest: true
|
||||
- name: Compile binaries
|
||||
env:
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
go-version: "1.24.x"
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
|
||||
@@ -6,10 +6,14 @@ on:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
schedule:
|
||||
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
|
||||
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/debian/**' # Only run on changes to the debian packaging files
|
||||
jobs:
|
||||
test-packages:
|
||||
if: github.event_name == 'schedule'
|
||||
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
|
||||
name: Test Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -28,15 +28,30 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
go-version: "1.24.x"
|
||||
- uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
args: >
|
||||
--timeout 10m
|
||||
--exclude-dirs=tests
|
||||
--disable errcheck
|
||||
--enable sqlclosecheck,misspell,gofmt,goimports,whitespace,gocritic
|
||||
- uses: dominikh/staticcheck-action@v1.3.1
|
||||
--enable sqlclosecheck,misspell,whitespace,gocritic
|
||||
- name: Run gofmt linter
|
||||
run: gofmt -d -e .
|
||||
|
||||
commitlint:
|
||||
name: Commit Linter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
version: "2024.1.1"
|
||||
install-go: false
|
||||
fetch-depth: 0
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
- name: Install commitlint
|
||||
run: |
|
||||
npm install --save-dev @commitlint/config-conventional @commitlint/cli
|
||||
echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js
|
||||
- name: Validate PR commits
|
||||
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
|
||||
|
||||
@@ -6,10 +6,14 @@ on:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
schedule:
|
||||
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
|
||||
- cron: '0 0 * * 1,4' # Runs at 00:00 UTC on Monday and Thursday
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'packaging/rpm/**' # Only run on changes to the rpm packaging files
|
||||
jobs:
|
||||
test-package:
|
||||
if: github.event_name == 'schedule'
|
||||
if: github.event_name == 'schedule' || github.event_name == 'pull_request'
|
||||
name: Test 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.23.x"]
|
||||
go-version: ["1.24.x"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
go-version: "1.24.x"
|
||||
- name: Install Postgres client
|
||||
run: sudo apt update && sudo apt install -y postgresql-client
|
||||
- name: Run integration tests
|
||||
|
||||
@@ -1,7 +1,112 @@
|
||||
Version 2.2.7 (April 1, 2025)
|
||||
-----------------------------
|
||||
|
||||
* test(api): update base URL after upgrading Hugo
|
||||
* refactor(rewrite): reorganize referer rules and remove obsolete mappings
|
||||
* refactor: combine feed icon handlers to use only `externalIconID`
|
||||
* fix(ui): update share feature to correctly select the title element and handle empty titles
|
||||
* fix(ui): update entry tags display logic to show links based on user authentication
|
||||
* fix(ui): remove touch-action style to prevent horizontal scrolling issues
|
||||
* fix(ui): log a warning for an empty client secret
|
||||
* fix(ui): change labels from "Read / Unread" to "Mark as Read"
|
||||
* fix(ui): avoid 500 errors and NaN when marking a deleted entry as read
|
||||
* fix(subscription): add `/rss/feed.xml` to the list of known feed URLs
|
||||
* fix(security): use a more restrictive CSP for untrusted content
|
||||
* fix(rewrite): remove obsolete rule for `webtoons.com`
|
||||
* fix(processor): add missing quotation marks to import comments
|
||||
* fix(googlereader): return enclosures in the `streamItemContentsHandler` response
|
||||
* fix: address minor issues detected by Go linters
|
||||
* feat(urlcleaner): add more Google Analytics parameters
|
||||
* feat(storage): reduce the number of SQL queries when fetching entry enclosures
|
||||
* feat(sanitizer): allow the `<u>` tag in feeds
|
||||
* feat(sanitizer): allow the `<b>` tag
|
||||
* feat(locale): update Polish translation
|
||||
* feat(locale): add Romanian translation
|
||||
* feat(integrations/ntfy): make ntfy topics configurable per feed
|
||||
* feat(googlereader): add a feed icon endpoint
|
||||
* feat: show database size on the about page
|
||||
* feat: add a `make add string` command to add new localized strings
|
||||
* docs: update README
|
||||
* docs: update client README to remove references to deprecated functions
|
||||
* ci: replace GitHub Issue Markdown templates with YAML forms
|
||||
* build(deps): bump `golangci/golangci-lint-action` from `6` to `7`
|
||||
* build(deps): bump `golang.org/x/term` from `0.29.0` to `0.30.0`
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.26.0` to` 0.28.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.35.0` to `0.38.0`
|
||||
* build(deps): bump `golang.org/x/image` from `0.24.0` to `0.25.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.33.0` to `0.36.0`
|
||||
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.21.3` to `2.22.4`
|
||||
* build(deps): bump `github.com/prometheus/client_golang`
|
||||
* build(deps): bump `github.com/golang-jwt/jwt/v5` from `5.2.1` to `5.2.2`
|
||||
* build(deps): bump `github.com/go-webauthn/webauthn` from `0.11.2` to `0.12.2`
|
||||
* build(deps): bump `github.com/go-jose/go-jose/v4` from `4.0.2` to `4.0.5`
|
||||
* build(deps): bump `github.com/coreos/go-oidc/v3` from `3.12.0` to `3.13.0`
|
||||
|
||||
Version 2.2.6 (February 22, 2025)
|
||||
---------------------------------
|
||||
|
||||
* test(encoding): add unit tests for `CharsetReader` function
|
||||
* refactor(xml): improve the performances of `NewXMLDecoder`
|
||||
* refactor(ui): remove superfluous cast
|
||||
* refactor(request): broaden an error condition when parsing cookies
|
||||
* refactor(processor): remove superfluous parenthesis
|
||||
* refactor(opml): don't define receivers on both values and pointer
|
||||
* refactor(model): simplify a condition
|
||||
* refactor(model): don't define methods both on instance and pointer
|
||||
* refactor(locale): sort JSON documents alphabetically by keys
|
||||
* refactor(locale): remove superfluous parenthesis
|
||||
* refactor(js): use proper types in `app.js`
|
||||
* refactor(js): replace the deprecated `window.pageYOffset` with `window.scollY`
|
||||
* refactor(js): remove a useless `return`
|
||||
* refactor(js): anchor `=` removal in `webauthn_handler.js` regex
|
||||
* refactor(js): add default value for parameter `fallbackSelf` in `goToPage` function
|
||||
* refactor(integration): don't use `defer` in a loop
|
||||
* refactor(icon): guard against a potential `null` dereference
|
||||
* refactor(date): use an else-if instead of two if statements
|
||||
* refactor(css): use shortcuts to declare padding
|
||||
* refactor(client): remove a useless cast
|
||||
* perf(sanitizer): remove two useless calls to `strings.ReplaceAll`
|
||||
* fix(ui): Redirect correctly post feed removal from category feeds list
|
||||
* fix(scraper): update TechCrunch scraper rule
|
||||
* fix(scraper): avoid encoding issue if charset meta tag is after 1024 bytes
|
||||
* fix(sanitizer): non-allowed attributes are not properly stripped
|
||||
* fix(sanitizer): correct HTML tag name from `tfooter` to `tfoot`
|
||||
* fix(rss): handle item title with `CDATA` content correctly
|
||||
* fix(locale): missing hyphen in `de_DE.json`
|
||||
* fix(css): avoid aside overflow on the pagination menu
|
||||
* fix(css): `--entry-content-aside-border-color` is missing from `system.css`
|
||||
* fix(api): return 500 response when JSON serialization fails
|
||||
* fix(api): JSON encoding is failing with dates at OAD and negative timezone offset
|
||||
* feat(urlcleaner): add trackers to the blocklist
|
||||
* feat(ui): open the `<details>` tag in edit feed page when the feature is enabled
|
||||
* feat(sanitizer): improve text truncation with better space handling
|
||||
* feat(sanitizer): allow `img` tags with only a `srcset` and no `src` attribute
|
||||
* feat(rss): add workaround for RSS item title with HTML content
|
||||
* feat(pushover): add integration with pushover.net
|
||||
* feat(processor): fetch YouTube watch time in bulk using the API
|
||||
* feat(locale): update Traditional Chinese translation
|
||||
* feat(locale): update Polish translation
|
||||
* feat(locale): update French translation
|
||||
* feat(locale): add Taiwanese POJ (nan-Latn-pehoeji)
|
||||
* feat(integration): update Linkace integration to support API v2
|
||||
* feat(integration): add webhook URL per feed
|
||||
* feat(integration): add Slack integration
|
||||
* feat(css): improve aside element position on smartphone
|
||||
* ci: update GitHub Actions workflows to use Go 1.24
|
||||
* ci: trigger packaging tests on pull requests
|
||||
* ci: add `commitlint` to validate PR commit messages
|
||||
* build(deps): bump `golang` in `/packaging/debian`
|
||||
* build(deps): bump `golang.org/x/term` from `0.28.0` to `0.29.0`
|
||||
* build(deps): bump `golang.org/x/oauth2` from `0.25.0` to `0.26.0`
|
||||
* build(deps): bump `golang.org/x/net` from `0.34.0` to `0.35.0`
|
||||
* build(deps): bump `golang.org/x/image` from `0.23.0` to `0.24.0`
|
||||
* build(deps): bump `golang.org/x/crypto` from `0.32.0` to `0.33.0`
|
||||
* build(deps): bump `github.com/PuerkitoBio/goquery` from `1.10.1` to `1.10.2`
|
||||
|
||||
Version 2.2.5 (January 20, 2025)
|
||||
--------------------------------
|
||||
|
||||
* tests(js): improve `.jshintrc` (strict comparison, etc...)
|
||||
* test(js): improve `.jshintrc` (strict comparison, etc...)
|
||||
* test(sanitizer): add a fuzzer
|
||||
* refactor(rewriter): use custom title case converter implementation instead of `golang.org/x/text/cases.Title()`
|
||||
* refactor(readingtime): replace `whatlanggo` package with an ad-hoc implementation
|
||||
|
||||
@@ -116,6 +116,16 @@ run:
|
||||
clean:
|
||||
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe $(APP)*.sha256
|
||||
|
||||
.PHONY: add-string
|
||||
add-string:
|
||||
cd internal/locale/translations && \
|
||||
for file in *.json; do \
|
||||
jq --indent 4 --arg key "$(KEY)" --arg val "$(VAL)" \
|
||||
'. + {($$key): $$val} | to_entries | sort_by(.key) | from_entries' "$$file" > tmp && \
|
||||
mv tmp "$$file"; \
|
||||
done
|
||||
|
||||
|
||||
test:
|
||||
go test -cover -race -count=1 ./...
|
||||
|
||||
|
||||
@@ -1,20 +1,110 @@
|
||||
Miniflux 2
|
||||
==========
|
||||
|
||||
Miniflux is a minimalist and opinionated feed reader:
|
||||
|
||||
- Written in Go (Golang)
|
||||
- Works only with Postgresql
|
||||
- Doesn't use any ORM
|
||||
- Doesn't use any complicated framework
|
||||
- Use only modern vanilla Javascript (ES6 and Fetch API)
|
||||
- Single binary compiled statically without dependency
|
||||
- The number of features is voluntarily limited
|
||||
|
||||
Miniflux is a minimalist and opinionated feed reader.
|
||||
It's simple, fast, lightweight and super easy to install.
|
||||
|
||||
Official website: <https://miniflux.app>
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
### Feed Reader
|
||||
|
||||
- Supported feed formats: Atom 0.3/1.0, RSS 1.0/2.0, and JSON Feed 1.0/1.1.
|
||||
- [OPML](https://en.wikipedia.org/wiki/OPML) file import/export and URL import.
|
||||
- Supports multiple attachments (podcasts, videos, music, and images enclosures).
|
||||
- Plays videos from YouTube directly inside Miniflux.
|
||||
- Organizes articles using categories and bookmarks.
|
||||
- Share individual articles publicly.
|
||||
- Fetches website icons (favicons).
|
||||
- Saves articles to third-party services.
|
||||
- Provides full-text search (powered by Postgres).
|
||||
- Available in 20 languages: Portuguese (Brazilian), Chinese (Simplified and Traditional), Dutch, English (US), Finnish, French, German, Greek, Hindi, Indonesian, Italian, Japanese, Polish, Romanian, Russian, Taiwanese POJ, Ukrainian, Spanish, and Turkish.
|
||||
|
||||
### Privacy
|
||||
|
||||
- Removes pixel trackers.
|
||||
- Strips tracking parameters from URLs (e.g., `utm_source`, `utm_medium`, `utm_campaign`, `fbclid`, etc.).
|
||||
- Retrieves original links when feeds are sourced from FeedBurner.
|
||||
- Opens external links with attributes `rel="noopener noreferrer" referrerpolicy="no-referrer"` for improved security.
|
||||
- Implements the HTTP header `Referrer-Policy: no-referrer` to prevent referrer leakage.
|
||||
- Provides a media proxy to avoid tracking and resolve mixed content warnings when using HTTPS.
|
||||
- Plays YouTube videos via the privacy-focused domain `youtube-nocookie.com`.
|
||||
- Supports alternative YouTube video players such as [Invidious](https://invidio.us).
|
||||
- Blocks external JavaScript to prevent tracking and enhance security.
|
||||
|
||||
### Bot Protection Bypass Mechanisms
|
||||
|
||||
- Optionally disable HTTP/2 to mitigate fingerprinting.
|
||||
- Allows configuration of a custom user agent.
|
||||
- Supports adding custom cookies for specific use cases.
|
||||
- Enables the use of proxies for enhanced privacy or bypassing restrictions.
|
||||
|
||||
### Content Manipulation
|
||||
|
||||
- Fetches the original article and extracts only the relevant content using a local Readability parser.
|
||||
- Allows custom scraper rules based on <abbr title="Cascading Style Sheets">CSS</abbr> selectors.
|
||||
- Supports custom rewriting rules for content manipulation.
|
||||
- Provides a regex filter to include or exclude articles based on specific patterns.
|
||||
- Optionally permits self-signed or invalid certificates (disabled by default).
|
||||
- Scrapes YouTube's website to retrieve video duration as read time or uses the YouTube API (disabled by default).
|
||||
|
||||
### User Interface
|
||||
|
||||
- Optimized stylesheet for readability.
|
||||
- Responsive design that adapts seamlessly to desktop, tablet, and mobile devices.
|
||||
- Minimalistic and distraction-free user interface.
|
||||
- No requirement to download an app from Apple App Store or Google Play Store.
|
||||
- Can be added directly to the home screen for quick access.
|
||||
- Supports a wide range of keyboard shortcuts for efficient navigation.
|
||||
- Optional touch gesture support for navigation on mobile devices.
|
||||
- Custom stylesheets and JavaScript to personalize the user interface to your preferences.
|
||||
- Themes:
|
||||
- Light (Sans-Serif)
|
||||
- Light (Serif)
|
||||
- Dark (Sans-Serif)
|
||||
- Dark (Serif)
|
||||
- System (Sans-Serif) – Automatically switches between Dark and Light themes based on system preferences.
|
||||
- System (Serif)
|
||||
|
||||
### Integrations
|
||||
|
||||
- 25+ integrations with third-party services: [Apprise](https://github.com/caronc/apprise), [Betula](https://sr.ht/~bouncepaw/betula/), [Cubox](https://cubox.cc/), [Discord](https://discord.com/), [Espial](https://github.com/jonschoning/espial), [Instapaper](https://www.instapaper.com/), [LinkAce](https://www.linkace.org/), [Linkding](https://github.com/sissbruecker/linkding), [LinkWarden](https://linkwarden.app/), [Matrix](https://matrix.org), [Notion](https://www.notion.com/), [Ntfy](https://ntfy.sh/), [Nunux Keeper](https://keeper.nunux.org/), [Pinboard](https://pinboard.in/), [Pocket](https://getpocket.com/), [Pushover](https://pushover.net), [RainDrop](https://raindrop.io/), [Readeck](https://readeck.org/en/), [Readwise Reader](https://readwise.io/read), [RssBridge](https://rss-bridge.org/), [Shaarli](https://github.com/shaarli/Shaarli), [Shiori](https://github.com/go-shiori/shiori), [Slack](https://slack.com/), [Telegram](https://telegram.org), [Wallabag](https://www.wallabag.org/), etc.
|
||||
- Bookmarklet for subscribing to websites directly from any web browser.
|
||||
- Webhooks for real-time notifications or custom integrations.
|
||||
- Compatibility with existing mobile applications using the Fever or Google Reader API.
|
||||
- REST API with client libraries available in [Go](https://github.com/miniflux/v2/tree/main/client) and [Python](https://github.com/miniflux/python-client).
|
||||
|
||||
### Authentication
|
||||
|
||||
- Local username and password.
|
||||
- Passkeys ([WebAuthn](https://en.wikipedia.org/wiki/WebAuthn)).
|
||||
- Google (OAuth2).
|
||||
- Generic OpenID Connect.
|
||||
- Reverse-Proxy authentication.
|
||||
|
||||
### Technical Stuff
|
||||
|
||||
- Written in [Go (Golang)](https://golang.org/).
|
||||
- Single binary compiled statically without dependency.
|
||||
- Works only with [PostgreSQL](https://www.postgresql.org/).
|
||||
- Does not use any ORM or any complicated frameworks.
|
||||
- Uses modern vanilla JavaScript only when necessary.
|
||||
- All static files are bundled into the application binary using the Go `embed` package.
|
||||
- Supports the Systemd `sd_notify` protocol for process monitoring.
|
||||
- Configures HTTPS automatically with Let's Encrypt.
|
||||
- Allows the use of custom <abbr title="Secure Sockets Layer">SSL</abbr> certificates.
|
||||
- Supports [HTTP/2](https://en.wikipedia.org/wiki/HTTP/2) when TLS is enabled.
|
||||
- Updates feeds in the background using an internal scheduler or a traditional cron job.
|
||||
- Sanitizes external content before rendering it.
|
||||
- Enforces a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) that permits only application JavaScript and blocks inline scripts and styles.
|
||||
- Uses native lazy loading for images and iframes.
|
||||
- Compatible only with modern browsers.
|
||||
- Adheres to the [Twelve-Factor App](https://12factor.net/) methodology.
|
||||
- Provides official Debian/RPM packages and pre-built binaries.
|
||||
- Publishes a Docker image to Docker Hub, GitHub Registry, and Quay.io Registry, with ARM architecture support.
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
|
||||
@@ -29,7 +119,7 @@ The Miniflux documentation is available here: <https://miniflux.app/docs/> ([Man
|
||||
- [Command Line Usage](https://miniflux.app/docs/cli.html)
|
||||
- [User Interface Usage](https://miniflux.app/docs/ui.html)
|
||||
- [Keyboard Shortcuts](https://miniflux.app/docs/keyboard_shortcuts.html)
|
||||
- [Integration with External Services](https://miniflux.app/docs/services.html)
|
||||
- [Integration with External Services](https://miniflux.app/docs/#integrations)
|
||||
- [Rewrite and Scraper Rules](https://miniflux.app/docs/rules.html)
|
||||
- [API Reference](https://miniflux.app/docs/api.html)
|
||||
- [Development](https://miniflux.app/docs/development.html)
|
||||
|
||||
+2
-2
@@ -27,10 +27,10 @@ import (
|
||||
|
||||
func main() {
|
||||
// Authentication with username/password:
|
||||
client := miniflux.New("https://api.example.org", "admin", "secret")
|
||||
client := miniflux.NewClient("https://api.example.org", "admin", "secret")
|
||||
|
||||
// Authentication with an API Key:
|
||||
client := miniflux.New("https://api.example.org", "my-secret-token")
|
||||
client := miniflux.NewClient("https://api.example.org", "my-secret-token")
|
||||
|
||||
// Fetch all feeds.
|
||||
feeds, err := client.Feeds()
|
||||
|
||||
@@ -18,6 +18,7 @@ type Client struct {
|
||||
}
|
||||
|
||||
// New returns a new Miniflux client.
|
||||
//
|
||||
// Deprecated: use NewClient instead.
|
||||
func New(endpoint string, credentials ...string) *Client {
|
||||
return NewClient(endpoint, credentials...)
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
|
||||
|
||||
func (r *request) buildClient() http.Client {
|
||||
return http.Client{
|
||||
Timeout: time.Duration(defaultTimeout * time.Second),
|
||||
Timeout: defaultTimeout * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,26 +3,26 @@ module miniflux.app/v2
|
||||
// +heroku goVersion go1.23
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.1
|
||||
github.com/PuerkitoBio/goquery v1.10.2
|
||||
github.com/andybalholm/brotli v1.1.1
|
||||
github.com/coreos/go-oidc/v3 v3.12.0
|
||||
github.com/go-webauthn/webauthn v0.11.2
|
||||
github.com/coreos/go-oidc/v3 v3.13.0
|
||||
github.com/go-webauthn/webauthn v0.12.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/prometheus/client_golang v1.20.5
|
||||
github.com/tdewolff/minify/v2 v2.21.3
|
||||
golang.org/x/crypto v0.32.0
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/net v0.34.0
|
||||
golang.org/x/oauth2 v0.25.0
|
||||
golang.org/x/term v0.28.0
|
||||
github.com/prometheus/client_golang v1.21.1
|
||||
github.com/tdewolff/minify/v2 v2.22.4
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/image v0.25.0
|
||||
golang.org/x/net v0.38.0
|
||||
golang.org/x/oauth2 v0.28.0
|
||||
golang.org/x/term v0.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/x v0.1.14 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/google/go-tpm v0.9.1 // indirect
|
||||
github.com/go-webauthn/x v0.1.19 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
github.com/google/go-tpm v0.9.3 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -30,19 +30,21 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.19 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.21 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
google.golang.org/protobuf v1.36.1 // indirect
|
||||
)
|
||||
|
||||
go 1.23
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
||||
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
||||
github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
|
||||
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
@@ -8,30 +8,30 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
|
||||
github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/coreos/go-oidc/v3 v3.13.0 h1:M66zd0pcc5VxvBNM4pB331Wrsanby+QomQYjN8HamW8=
|
||||
github.com/coreos/go-oidc/v3 v3.13.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
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.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
|
||||
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
|
||||
github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0=
|
||||
github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/go-webauthn/webauthn v0.12.2 h1:yLaNPgBUEXDQtWnOjhsGhMMCEWbXwjg/aNkC8riJQI8=
|
||||
github.com/go-webauthn/webauthn v0.12.2/go.mod h1:Q8SZPPj4sZ469fNTcQXxRpzJOdb30jQrn/36FX8jilA=
|
||||
github.com/go-webauthn/x v0.1.19 h1:IUfdHiBRoTdujpBA/14qbrMXQ3LGzYe/PRGWdZcmudg=
|
||||
github.com/go-webauthn/x v0.1.19/go.mod h1:C5arLuTQ3pVHKPw89v7CDGnqAZSZJj+4Jnr40dsn7tk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
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.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
|
||||
github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
|
||||
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
@@ -44,23 +44,22 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
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.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
||||
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tdewolff/minify/v2 v2.21.3 h1:KmhKNGrN/dGcvb2WDdB5yA49bo37s+hcD8RiF+lioV8=
|
||||
github.com/tdewolff/minify/v2 v2.21.3/go.mod h1:iGxHaGiONAnsYuo8CRyf8iPUcqRJVB/RhtEcTpqS7xw=
|
||||
github.com/tdewolff/parse/v2 v2.7.19 h1:7Ljh26yj+gdLFEq/7q9LT4SYyKtwQX4ocNrj45UCePg=
|
||||
github.com/tdewolff/parse/v2 v2.7.19/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
|
||||
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tdewolff/minify/v2 v2.22.4 h1:0/8K2fheOuYr5B4e5oCE1hGBVX6DQHLP0EGzdsDlYeg=
|
||||
github.com/tdewolff/minify/v2 v2.22.4/go.mod h1:K/R8TT7aivpcU8QCNUU1UdR6etfnFPr7L11TO/X7shk=
|
||||
github.com/tdewolff/parse/v2 v2.7.21 h1:OCuPFtGr4mXdnfKikQlUb0n654ROJANhBqCk+wioJ/A=
|
||||
github.com/tdewolff/parse/v2 v2.7.21/go.mod h1:I7TXO37t3aSG9SlPUBefAhgIF8nt7yYUwVGgETIoBcA=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
@@ -72,10 +71,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
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/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -90,10 +89,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
|
||||
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
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=
|
||||
@@ -112,8 +111,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
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=
|
||||
@@ -123,8 +122,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -133,8 +132,9 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
@@ -142,7 +142,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -48,7 +48,7 @@ func newIntegrationTestConfig() *integrationTestConfig {
|
||||
testFeedURL: getDefaultEnvValues("TEST_MINIFLUX_FEED_URL", "https://miniflux.app/feed.xml"),
|
||||
testFeedTitle: getDefaultEnvValues("TEST_MINIFLUX_FEED_TITLE", "Miniflux"),
|
||||
testSubscriptionTitle: getDefaultEnvValues("TEST_MINIFLUX_SUBSCRIPTION_TITLE", "Miniflux Releases"),
|
||||
testWebsiteURL: getDefaultEnvValues("TEST_MINIFLUX_WEBSITE_URL", "https://miniflux.app"),
|
||||
testWebsiteURL: getDefaultEnvValues("TEST_MINIFLUX_WEBSITE_URL", "https://miniflux.app/"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -189,11 +189,15 @@ func (p *Parser) parseLines(lines []string) (err error) {
|
||||
case "PROXY_PRIVATE_KEY":
|
||||
slog.Warn("The PROXY_PRIVATE_KEY environment variable is deprecated, use MEDIA_PROXY_PRIVATE_KEY instead")
|
||||
randomKey := make([]byte, 16)
|
||||
rand.Read(randomKey)
|
||||
if _, err := rand.Read(randomKey); err != nil {
|
||||
return fmt.Errorf("config: unable to generate random key: %w", err)
|
||||
}
|
||||
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
|
||||
case "MEDIA_PROXY_PRIVATE_KEY":
|
||||
randomKey := make([]byte, 16)
|
||||
rand.Read(randomKey)
|
||||
if _, err := rand.Read(randomKey); err != nil {
|
||||
return fmt.Errorf("config: unable to generate random key: %w", err)
|
||||
}
|
||||
p.opts.mediaProxyPrivateKey = parseBytes(value, randomKey)
|
||||
case "MEDIA_PROXY_CUSTOM_URL":
|
||||
p.opts.mediaProxyCustomURL = parseString(value, defaultMediaProxyURL)
|
||||
|
||||
@@ -5,6 +5,8 @@ package database // import "miniflux.app/v2/internal/database"
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
)
|
||||
|
||||
var schemaVersion = len(migrations)
|
||||
@@ -982,4 +984,82 @@ var migrations = []func(tx *sql.Tx, driver string) error{
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN slack_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN slack_webhook_link text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
_, err = tx.Exec(`ALTER TABLE feeds ADD COLUMN webhook_url text default '';`)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN pushover_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN pushover_user text default '';
|
||||
ALTER TABLE integrations ADD COLUMN pushover_token text default '';
|
||||
ALTER TABLE integrations ADD COLUMN pushover_device text default '';
|
||||
ALTER TABLE integrations ADD COLUMN pushover_prefix text default '';
|
||||
|
||||
ALTER TABLE feeds ADD COLUMN pushover_enabled bool default 'f';
|
||||
ALTER TABLE feeds ADD COLUMN pushover_priority int default '0';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE feeds ADD COLUMN ntfy_topic text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE icons ADD COLUMN external_id text default '';
|
||||
CREATE UNIQUE INDEX icons_external_id_idx ON icons USING btree(external_id) WHERE external_id <> '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx, _ string) (err error) {
|
||||
_, err = tx.Exec(`
|
||||
DECLARE id_cursor CURSOR FOR
|
||||
SELECT
|
||||
id
|
||||
FROM icons
|
||||
WHERE external_id = ''
|
||||
FOR UPDATE`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Exec("CLOSE id_cursor")
|
||||
|
||||
for {
|
||||
var id int64
|
||||
|
||||
if err := tx.QueryRow(`FETCH NEXT FROM id_cursor`).Scan(&id); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(
|
||||
`
|
||||
UPDATE icons SET external_id = $1 WHERE id = $2
|
||||
`,
|
||||
crypto.GenerateRandomStringHex(20), id)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -827,6 +827,14 @@ func move(stream Stream, destination Stream, store *storage.Storage, userID int6
|
||||
return store.UpdateFeed(feed)
|
||||
}
|
||||
|
||||
func (h *handler) feedIconURL(f *model.Feed) string {
|
||||
if f.Icon != nil && f.Icon.ExternalIconID != "" {
|
||||
return config.Opts.RootURL() + route.Path(h.router, "feedIcon", "externalIconID", f.Icon.ExternalIconID)
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) editSubscriptionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := request.UserID(r)
|
||||
clientIP := request.ClientIP(r)
|
||||
@@ -950,6 +958,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
||||
)
|
||||
|
||||
builder := h.store.NewEntryQueryBuilder(userID)
|
||||
builder.WithEnclosures()
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithEntryIDs(itemIDs)
|
||||
builder.WithSorting(model.DefaultSortingOrder, requestModifiers.SortDirection)
|
||||
@@ -1207,6 +1216,7 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
result.Subscriptions = make([]subscription, 0)
|
||||
for _, feed := range feeds {
|
||||
result.Subscriptions = append(result.Subscriptions, subscription{
|
||||
@@ -1215,7 +1225,7 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
|
||||
URL: feed.FeedURL,
|
||||
Categories: []subscriptionCategory{{fmt.Sprintf(UserLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
|
||||
HTMLURL: feed.SiteURL,
|
||||
IconURL: "", // TODO: Icons are base64 encoded in the DB.
|
||||
IconURL: h.feedIconURL(feed),
|
||||
})
|
||||
}
|
||||
json.OK(w, r, result)
|
||||
|
||||
@@ -8,7 +8,7 @@ import "net/http"
|
||||
// CookieValue returns the cookie value.
|
||||
func CookieValue(r *http.Request, name string) string {
|
||||
cookie, err := r.Cookie(name)
|
||||
if err == http.ErrNoCookie {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ type Builder struct {
|
||||
statusCode int
|
||||
headers map[string]string
|
||||
enableCompression bool
|
||||
body interface{}
|
||||
body any
|
||||
}
|
||||
|
||||
// WithStatus uses the given status code to build the response.
|
||||
@@ -41,7 +41,7 @@ func (b *Builder) WithHeader(key, value string) *Builder {
|
||||
}
|
||||
|
||||
// WithBody uses the given body to build the response.
|
||||
func (b *Builder) WithBody(body interface{}) *Builder {
|
||||
func (b *Builder) WithBody(body any) *Builder {
|
||||
b.body = body
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func ServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusInternalServerError)
|
||||
builder.WithHeader("Content-Security-Policy", `default-src 'self'`)
|
||||
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBody(err)
|
||||
@@ -61,7 +61,7 @@ func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusBadRequest)
|
||||
builder.WithHeader("Content-Security-Policy", `default-src 'self'`)
|
||||
builder.WithHeader("Content-Security-Policy", response.ContentSecurityPolicyForUntrustedContent)
|
||||
builder.WithHeader("Content-Type", "text/html; charset=utf-8")
|
||||
builder.WithHeader("Cache-Control", "no-cache, max-age=0, must-revalidate, no-store")
|
||||
builder.WithBody(err)
|
||||
|
||||
@@ -16,19 +16,31 @@ import (
|
||||
const contentTypeHeader = `application/json`
|
||||
|
||||
// OK creates a new JSON response with a 200 status code.
|
||||
func OK(w http.ResponseWriter, r *http.Request, body interface{}) {
|
||||
func OK(w http.ResponseWriter, r *http.Request, body any) {
|
||||
responseBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSON(body))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
// Created sends a created response to the client.
|
||||
func Created(w http.ResponseWriter, r *http.Request, body interface{}) {
|
||||
func Created(w http.ResponseWriter, r *http.Request, body any) {
|
||||
responseBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusCreated)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSON(body))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -62,10 +74,17 @@ func ServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(err)
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusInternalServerError)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSONError(err))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -84,10 +103,17 @@ func BadRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(err)
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusBadRequest)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSONError(err))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -105,10 +131,17 @@ func Unauthorized(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(errors.New("access unauthorized"))
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusUnauthorized)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSONError(errors.New("access unauthorized")))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -126,10 +159,17 @@ func Forbidden(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(errors.New("access forbidden"))
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusForbidden)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSONError(errors.New("access forbidden")))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
@@ -147,27 +187,29 @@ func NotFound(w http.ResponseWriter, r *http.Request) {
|
||||
),
|
||||
)
|
||||
|
||||
responseBody, jsonErr := generateJSONError(errors.New("resource not found"))
|
||||
if jsonErr != nil {
|
||||
slog.Error("Unable to generate JSON error", slog.Any("error", jsonErr))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
builder := response.New(w, r)
|
||||
builder.WithStatus(http.StatusNotFound)
|
||||
builder.WithHeader("Content-Type", contentTypeHeader)
|
||||
builder.WithBody(toJSONError(errors.New("resource not found")))
|
||||
builder.WithBody(responseBody)
|
||||
builder.Write()
|
||||
}
|
||||
|
||||
func toJSONError(err error) []byte {
|
||||
func generateJSONError(err error) ([]byte, error) {
|
||||
type errorMsg struct {
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
|
||||
return toJSON(errorMsg{ErrorMessage: err.Error()})
|
||||
}
|
||||
|
||||
func toJSON(v interface{}) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
encodedBody, err := json.Marshal(errorMsg{ErrorMessage: err.Error()})
|
||||
if err != nil {
|
||||
slog.Error("Unable to marshal JSON response", slog.Any("error", err))
|
||||
return []byte("")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b
|
||||
return encodedBody, nil
|
||||
}
|
||||
|
||||
@@ -293,12 +293,12 @@ func TestBuildInvalidJSONResponse(t *testing.T) {
|
||||
handler.ServeHTTP(w, r)
|
||||
resp := w.Result()
|
||||
|
||||
expectedStatusCode := http.StatusOK
|
||||
expectedStatusCode := http.StatusInternalServerError
|
||||
if resp.StatusCode != expectedStatusCode {
|
||||
t.Fatalf(`Unexpected status code, got %d instead of %d`, resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
expectedBody := ``
|
||||
expectedBody := `{"error_message":"json: unsupported type: chan int"}`
|
||||
actualBody := w.Body.String()
|
||||
if actualBody != expectedBody {
|
||||
t.Fatalf(`Unexpected body, got %s instead of %s`, actualBody, expectedBody)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package response // import "miniflux.app/v2/internal/http/response"
|
||||
|
||||
// ContentSecurityPolicyForUntrustedContent is the default CSP for untrusted content.
|
||||
// default-src 'none' disables all content sources
|
||||
// form-action 'none' disables all form submissions
|
||||
// sandbox enables a sandbox for the requested resource
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/form-action
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/sandbox
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
|
||||
const ContentSecurityPolicyForUntrustedContent = `default-src 'none'; form-action 'none'; sandbox;`
|
||||
@@ -69,7 +69,7 @@ func (c *Client) SendNotification(feed *model.Feed, entries model.Entries) error
|
||||
if err != nil {
|
||||
return fmt.Errorf("apprise: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("apprise: unable to send a notification: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
|
||||
@@ -82,7 +82,7 @@ func (c *Client) SendDiscordMsg(feed *model.Feed, entries model.Entries) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("discord: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
|
||||
|
||||
@@ -23,11 +23,13 @@ import (
|
||||
"miniflux.app/v2/internal/integration/omnivore"
|
||||
"miniflux.app/v2/internal/integration/pinboard"
|
||||
"miniflux.app/v2/internal/integration/pocket"
|
||||
"miniflux.app/v2/internal/integration/pushover"
|
||||
"miniflux.app/v2/internal/integration/raindrop"
|
||||
"miniflux.app/v2/internal/integration/readeck"
|
||||
"miniflux.app/v2/internal/integration/readwise"
|
||||
"miniflux.app/v2/internal/integration/shaarli"
|
||||
"miniflux.app/v2/internal/integration/shiori"
|
||||
"miniflux.app/v2/internal/integration/slack"
|
||||
"miniflux.app/v2/internal/integration/telegrambot"
|
||||
"miniflux.app/v2/internal/integration/wallabag"
|
||||
"miniflux.app/v2/internal/integration/webhook"
|
||||
@@ -473,35 +475,47 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
}
|
||||
|
||||
if userIntegrations.WebhookEnabled {
|
||||
var webhookURL string
|
||||
if feed.WebhookURL != "" {
|
||||
webhookURL = feed.WebhookURL
|
||||
} else {
|
||||
webhookURL = userIntegrations.WebhookURL
|
||||
}
|
||||
|
||||
slog.Debug("Sending new entries to Webhook",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("webhook_url", userIntegrations.WebhookURL),
|
||||
slog.String("webhook_url", webhookURL),
|
||||
)
|
||||
|
||||
webhookClient := webhook.NewClient(userIntegrations.WebhookURL, userIntegrations.WebhookSecret)
|
||||
webhookClient := webhook.NewClient(webhookURL, userIntegrations.WebhookSecret)
|
||||
if err := webhookClient.SendNewEntriesWebhookEvent(feed, entries); err != nil {
|
||||
slog.Debug("Unable to send new entries to Webhook",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("webhook_url", userIntegrations.WebhookURL),
|
||||
slog.String("webhook_url", webhookURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.NtfyEnabled && feed.NtfyEnabled {
|
||||
ntfyTopic := feed.NtfyTopic
|
||||
if ntfyTopic == "" {
|
||||
ntfyTopic = userIntegrations.NtfyTopic
|
||||
}
|
||||
slog.Debug("Sending new entries to Ntfy",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("topic", ntfyTopic),
|
||||
)
|
||||
|
||||
client := ntfy.NewClient(
|
||||
userIntegrations.NtfyURL,
|
||||
userIntegrations.NtfyTopic,
|
||||
ntfyTopic,
|
||||
userIntegrations.NtfyAPIToken,
|
||||
userIntegrations.NtfyUsername,
|
||||
userIntegrations.NtfyPassword,
|
||||
@@ -553,6 +567,42 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.SlackEnabled {
|
||||
slog.Debug("Sending new entries to Slack",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
)
|
||||
|
||||
client := slack.NewClient(
|
||||
userIntegrations.SlackWebhookLink,
|
||||
)
|
||||
|
||||
if err := client.SendSlackMsg(feed, entries); err != nil {
|
||||
slog.Warn("Unable to send new entries to Slack", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.PushoverEnabled && feed.PushoverEnabled {
|
||||
slog.Debug("Sending new entries to Pushover",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int("nb_entries", len(entries)),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
)
|
||||
|
||||
client := pushover.New(
|
||||
userIntegrations.PushoverUser,
|
||||
userIntegrations.PushoverToken,
|
||||
feed.PushoverPriority,
|
||||
userIntegrations.PushoverDevice,
|
||||
userIntegrations.PushoverPrefix,
|
||||
)
|
||||
|
||||
if err := client.SendMessages(feed, entries); err != nil {
|
||||
slog.Warn("Unable to send new entries to Pushover", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Integrations that only support sending individual entries
|
||||
if userIntegrations.TelegramBotEnabled {
|
||||
for _, entry := range entries {
|
||||
|
||||
@@ -35,7 +35,7 @@ func (c *Client) AddURL(entryURL, entryTitle string) error {
|
||||
return c == ',' || c == ' '
|
||||
}
|
||||
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/links")
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v2/links")
|
||||
if err != nil {
|
||||
return fmt.Errorf("linkace: invalid API endpoint: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pushover // import "miniflux.app/v2/internal/integration/pushover"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClientTimeout = 10 * time.Second
|
||||
defaultPushoverURL = "https://api.pushover.net"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
prefix string
|
||||
|
||||
token string
|
||||
user string
|
||||
device string
|
||||
|
||||
priority int
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Token string `json:"token"`
|
||||
User string `json:"user"`
|
||||
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
|
||||
URL string `json:"url"`
|
||||
URLTitle string `json:"url_title"`
|
||||
Device string `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
User string `json:"user"`
|
||||
Errors []string `json:"errors"`
|
||||
Status int `json:"status"`
|
||||
Request string `json:"request"`
|
||||
}
|
||||
|
||||
func New(user, token string, priority int, device, urlPrefix string) *Client {
|
||||
if urlPrefix == "" {
|
||||
urlPrefix = defaultPushoverURL
|
||||
}
|
||||
if priority < -2 {
|
||||
priority = -2
|
||||
}
|
||||
if priority > 2 {
|
||||
priority = 2
|
||||
}
|
||||
|
||||
return &Client{
|
||||
user: user,
|
||||
token: token,
|
||||
device: device,
|
||||
prefix: urlPrefix,
|
||||
priority: priority,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SendMessages(feed *model.Feed, entries model.Entries) error {
|
||||
if c.token == "" || c.user == "" {
|
||||
return fmt.Errorf("pushover token and user are required")
|
||||
}
|
||||
for _, entry := range entries {
|
||||
msg := &Message{
|
||||
User: c.user,
|
||||
Token: c.token,
|
||||
Device: c.device,
|
||||
|
||||
Message: entry.Title,
|
||||
Title: feed.Title,
|
||||
Priority: c.priority,
|
||||
URL: entry.URL,
|
||||
}
|
||||
|
||||
slog.Debug("Sending Pushover message",
|
||||
slog.Int("priority", msg.Priority),
|
||||
slog.String("message", msg.Message),
|
||||
slog.String("entry_url", msg.URL),
|
||||
)
|
||||
|
||||
if err := c.makeRequest(msg); err != nil {
|
||||
return fmt.Errorf("c.makeRequest: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) makeRequest(payload *Message) error {
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("json.Marshal: %w", err)
|
||||
}
|
||||
url := c.prefix + "/1/messages.json"
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("http.NewRequest: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("httpClient.Do: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
errorMessage := resp.Status
|
||||
|
||||
var errResp ErrorResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
|
||||
if len(errResp.Errors) > 0 {
|
||||
errorMessage = strings.Join(errResp.Errors, ",")
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("pushover API error (%d): %s", resp.StatusCode, errorMessage)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Slack Webhooks documentation: https://api.slack.com/messaging/webhooks
|
||||
|
||||
package slack // import "miniflux.app/v2/internal/integration/slack"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
const slackMsgColor = "#5865F2"
|
||||
|
||||
type Client struct {
|
||||
webhookURL string
|
||||
}
|
||||
|
||||
func NewClient(webhookURL string) *Client {
|
||||
return &Client{webhookURL: webhookURL}
|
||||
}
|
||||
|
||||
func (c *Client) SendSlackMsg(feed *model.Feed, entries model.Entries) error {
|
||||
for _, entry := range entries {
|
||||
requestBody, err := json.Marshal(&slackMessage{
|
||||
Attachments: []slackAttachments{
|
||||
{
|
||||
Title: "RSS feed update from Miniflux",
|
||||
Color: slackMsgColor,
|
||||
Fields: []slackFields{
|
||||
{
|
||||
Title: "Updated feed",
|
||||
Value: feed.Title,
|
||||
},
|
||||
{
|
||||
Title: "Article title",
|
||||
Value: entry.Title,
|
||||
},
|
||||
{
|
||||
Title: "Article link",
|
||||
Value: entry.URL,
|
||||
},
|
||||
{
|
||||
Title: "Author",
|
||||
Value: entry.Author,
|
||||
Short: true,
|
||||
},
|
||||
{
|
||||
Title: "Source website",
|
||||
Value: urllib.RootURL(feed.SiteURL),
|
||||
Short: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: unable to encode request body: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: unable to create request: %v", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
|
||||
slog.Debug("Sending Slack notification",
|
||||
slog.String("webhookURL", c.webhookURL),
|
||||
slog.String("title", feed.Title),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: unable to send request: %v", err)
|
||||
}
|
||||
response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("slack: unable to send a notification: url=%s status=%d", c.webhookURL, response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type slackFields struct {
|
||||
Title string `json:"title"`
|
||||
Value string `json:"value"`
|
||||
Short bool `json:"short,omitempty"`
|
||||
}
|
||||
|
||||
type slackAttachments struct {
|
||||
Title string `json:"title"`
|
||||
Color string `json:"color"`
|
||||
Fields []slackFields `json:"fields"`
|
||||
}
|
||||
|
||||
type slackMessage struct {
|
||||
Attachments []slackAttachments `json:"attachments"`
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func TestMissingTranslations(t *testing.T) {
|
||||
|
||||
for key := range references {
|
||||
if _, found := messages[key]; !found {
|
||||
t.Fatalf(`Translation key %q not found in language %q`, key, language)
|
||||
t.Errorf(`Translation key %q not found in language %q`, key, language)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,24 +91,26 @@ func TestMissingTranslations(t *testing.T) {
|
||||
|
||||
func TestTranslationFilePluralForms(t *testing.T) {
|
||||
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,
|
||||
"de_DE": 2,
|
||||
"el_EL": 2,
|
||||
"en_US": 2,
|
||||
"es_ES": 2,
|
||||
"fi_FI": 2,
|
||||
"fr_FR": 2,
|
||||
"hi_IN": 2,
|
||||
"id_ID": 1,
|
||||
"it_IT": 2,
|
||||
"ja_JP": 1,
|
||||
"nan_Latn_pehoeji": 1,
|
||||
"nl_NL": 2,
|
||||
"pl_PL": 3,
|
||||
"pt_BR": 2,
|
||||
"ro_RO": 3,
|
||||
"ru_RU": 3,
|
||||
"tr_TR": 2,
|
||||
"uk_UA": 3,
|
||||
"zh_CN": 1,
|
||||
"zh_TW": 1,
|
||||
}
|
||||
for language := range AvailableLanguages {
|
||||
messages, err := loadTranslationFile(language)
|
||||
|
||||
+20
-18
@@ -5,22 +5,24 @@ package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
// AvailableLanguages is the list of available languages.
|
||||
var AvailableLanguages = map[string]string{
|
||||
"en_US": "English",
|
||||
"es_ES": "Español",
|
||||
"fr_FR": "Français",
|
||||
"de_DE": "Deutsch",
|
||||
"pl_PL": "Polski",
|
||||
"pt_BR": "Português Brasileiro",
|
||||
"zh_CN": "简体中文",
|
||||
"zh_TW": "繁體中文",
|
||||
"nl_NL": "Nederlands",
|
||||
"ru_RU": "Русский",
|
||||
"it_IT": "Italiano",
|
||||
"ja_JP": "日本語",
|
||||
"tr_TR": "Türkçe",
|
||||
"el_EL": "Ελληνικά",
|
||||
"fi_FI": "Suomi",
|
||||
"hi_IN": "हिन्दी",
|
||||
"uk_UA": "Українська",
|
||||
"id_ID": "Bahasa Indonesia",
|
||||
"de_DE": "Deutsch",
|
||||
"el_EL": "Ελληνικά",
|
||||
"en_US": "English",
|
||||
"es_ES": "Español",
|
||||
"fi_FI": "Suomi",
|
||||
"fr_FR": "Français",
|
||||
"hi_IN": "हिन्दी",
|
||||
"id_ID": "Bahasa Indonesia",
|
||||
"it_IT": "Italiano",
|
||||
"ja_JP": "日本語",
|
||||
"nan_Latn_pehoeji": "Pe̍h-ōe-jī",
|
||||
"nl_NL": "Nederlands",
|
||||
"pl_PL": "Polski",
|
||||
"pt_BR": "Português Brasileiro",
|
||||
"ro_RO": "Română",
|
||||
"ru_RU": "Русский",
|
||||
"tr_TR": "Türkçe",
|
||||
"uk_UA": "Українська",
|
||||
"zh_CN": "简体中文",
|
||||
"zh_TW": "繁體中文",
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
// See https://localization-guide.readthedocs.io/en/latest/l10n/pluralforms.html
|
||||
// And http://www.unicode.org/cldr/charts/29/supplemental/language_plural_rules.html
|
||||
var pluralForms = map[string](func(n int) int){
|
||||
var pluralForms = map[string]func(n int) int{
|
||||
// nplurals=2; plural=(n != 1);
|
||||
"default": func(n int) int {
|
||||
if n != 1 {
|
||||
@@ -71,6 +71,16 @@ var pluralForms = map[string](func(n int) int){
|
||||
}
|
||||
return 0
|
||||
},
|
||||
// nplurals=3; plural=(n==1 ? 0 : n==0 || (n%100 > 0 && n%100 < 20) ? 1 : 2);
|
||||
"ro_RO": func(n int) int {
|
||||
switch {
|
||||
case n == 1:
|
||||
return 0
|
||||
case n == 0 || (n%100 > 0 && n%100 < 20):
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
},
|
||||
"ru_RU": pluralFormRuSrUa,
|
||||
// nplurals=2; plural=(n > 1);
|
||||
"tr_TR": func(n int) int {
|
||||
@@ -88,6 +98,9 @@ var pluralForms = map[string](func(n int) int){
|
||||
"zh_TW": func(n int) int {
|
||||
return 0
|
||||
},
|
||||
"nan_Latn_pehoeji": 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);
|
||||
|
||||
@@ -49,6 +49,11 @@ func TestPluralRules(t *testing.T) {
|
||||
2: 1,
|
||||
5: 1,
|
||||
},
|
||||
"ro_RO": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 1,
|
||||
},
|
||||
"ru_RU": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
@@ -77,6 +82,10 @@ func TestPluralRules(t *testing.T) {
|
||||
1: 0,
|
||||
5: 0,
|
||||
},
|
||||
"nan_Latn_pehoeji": {
|
||||
1: 0,
|
||||
5: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for rule, values := range scenarios {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,589 @@
|
||||
{
|
||||
"action.cancel": "Chhú-siau",
|
||||
"action.download": "Lia̍h----loh-lâi",
|
||||
"action.edit": "Pian-chi̍p",
|
||||
"action.home_screen": "Chng tī chú ōe-bīn",
|
||||
"action.import": "Hōe--li̍p",
|
||||
"action.login": "Teng-lo̍k",
|
||||
"action.or": "ah-sī",
|
||||
"action.remove": "Thâi tiāu",
|
||||
"action.remove_feed": "Thâi tiāu chit ê siau-sit lâi-goân",
|
||||
"action.save": "Pó-chûn",
|
||||
"action.subscribe": "Tēng",
|
||||
"action.update": "Ōaⁿ-sin",
|
||||
"alert.account_linked": "Í-keng kah lí ê gōa-pō͘ kháu-chō kiat chòe-hé--ah!",
|
||||
"alert.account_unlinked": "Kah lí ê gōa-pō͘ kháu-chō ê kiat í-keng phah khui--ah!",
|
||||
"alert.background_feed_refresh": "Tng leh pōe-āu ōaⁿ-sin só͘-ū siau-sit lâi-goân, lí ē-sái kè-sio̍k sú-iōng Miniflux。",
|
||||
"alert.feed_error": "Chit ê siau-sit lâi-goân ū būn-tôe",
|
||||
"alert.no_bookmark": "Chit-má ah bô siu-chông",
|
||||
"alert.no_category": "Chit-má ah bô lūi-pia̍t",
|
||||
"alert.no_category_entry": "Chit ê lūi-pah ah bô siau-sit",
|
||||
"alert.no_feed": "Chit-má ah bô siau-sit lâi-goân",
|
||||
"alert.no_feed_entry": "Chit ê siau-sit lâi-goân lāi bô siau-sit",
|
||||
"alert.no_feed_in_category": "Bô chit ê lūi-pia̍t ê siau-sit lâi-goân",
|
||||
"alert.no_history": "Chit-má ah bô kì-lo̍k",
|
||||
"alert.no_search_result": "Bô hû-ha̍p ê chhiau-chhē kiat-kó",
|
||||
"alert.no_shared_entry": "Chit-má ah bô hun-hióng ê siau-sit",
|
||||
"alert.no_tag_entry": "Bô kah chit ê khan-á ū hû-ha̍p ê siau-sit",
|
||||
"alert.no_unread_entry": "Chit-má ah-bô tha̍k kè ê siau-sit",
|
||||
"alert.no_user": "Lí sī ûi-it ê sú-iōng-lâng",
|
||||
"alert.pocket_linked": "Í-keng kā lí ê Pocket kháu-chō kiat chòe-hé--ah!",
|
||||
"alert.prefs_saved": "Siat-tēng í-keng pó-chûn--ah!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"Lí í-keng ín-khí siuⁿ chōe pái siau-sit lâi-goân ōaⁿ-sin, chhiáⁿ tán-hāu %d hun-cheng āu koh chhì-khòaⁿ-māi."
|
||||
],
|
||||
"confirm.loading": "Tng leh chip-hêng…",
|
||||
"confirm.no": "Hóⁿ",
|
||||
"confirm.question": "Kám ū khak-tēng?",
|
||||
"confirm.question.refresh": "Kám beh kiông-chè têng lia̍h?",
|
||||
"confirm.yes": "Sī",
|
||||
"enclosure_media_controls.seek": "sóa-ūi:",
|
||||
"enclosure_media_controls.seek.title": "sóa %s bió",
|
||||
"enclosure_media_controls.speed": "Sok-tō͘",
|
||||
"enclosure_media_controls.speed.faster": "Cheng-ka sok-tō͘",
|
||||
"enclosure_media_controls.speed.faster.title": "Cheng-ka sok-tō͘ %sx",
|
||||
"enclosure_media_controls.speed.reset": "Têng siat-tēng",
|
||||
"enclosure_media_controls.speed.reset.title": "Têng siat-tēng pàng ê sok-tō͘ chòe 1x",
|
||||
"enclosure_media_controls.speed.slower": "Pàng bān",
|
||||
"enclosure_media_controls.speed.slower.title": "Pàng bān %sx",
|
||||
"entry.bookmark.toast.off": "Chhú-siau siu-chông chòe soah",
|
||||
"entry.bookmark.toast.on": "Sin cheng-ka siu-chông chòe soah",
|
||||
"entry.bookmark.toggle.off": "Chhú-siau siu-chông",
|
||||
"entry.bookmark.toggle.on": "Siu-chông khí-lâi",
|
||||
"entry.comments.label": "Hôe-èng",
|
||||
"entry.comments.title": "Khòaⁿ hôe-èng",
|
||||
"entry.estimated_reading_time": [
|
||||
"Ài %d hun-cheng lâi tha̍k"
|
||||
],
|
||||
"entry.external_link.label": "Gōa-pō͘ liân-kiat",
|
||||
"entry.save.completed": "Pó-chûn chò soah",
|
||||
"entry.save.label": "Pó-chûn",
|
||||
"entry.save.title": "Pó-chûn chit ê siau-sit",
|
||||
"entry.save.toast.completed": "Pó-chûn chò soah",
|
||||
"entry.scraper.completed": "Lia̍h soah--ah",
|
||||
"entry.scraper.label": "Lia̍h--lo̍h-lâi",
|
||||
"entry.scraper.title": "Lia̍h goân-tóe lōe-iông",
|
||||
"entry.share.label": "Hun-hióng",
|
||||
"entry.share.title": "Hun-hióng chit ê siau-sit",
|
||||
"entry.shared_entry.label": "Hun-hióng",
|
||||
"entry.shared_entry.title": "Phah khui kong-khai ê liân-kiat",
|
||||
"entry.state.loading": "Tng leh chip-hêng…",
|
||||
"entry.state.saving": "Tng leh pó-chûn…",
|
||||
"entry.status.mark_as_read": "Chù chòe tha̍k kè",
|
||||
"entry.status.title": "Kái chōng-thài",
|
||||
"entry.status.toast.read": "Chù chòe tha̍k kè chòe soah",
|
||||
"entry.status.toast.unread": "Chù chòe ah-bōe tha̍k chòe soah",
|
||||
"entry.status.mark_as_unread": "Chù chòe ah-bōe tha̍k",
|
||||
"entry.tags.label": "Khan-á:",
|
||||
"entry.unshare.label": "Chhú-siau hun-hióng",
|
||||
"error.api_key_already_exists": "Chit ê API só-sî í-keng chûn-chāi",
|
||||
"error.bad_credentials": "M̄-tio̍h ê kháu-chō miâ ah-sī bi̍t-bé.",
|
||||
"error.category_already_exists": "Lūi-pia̍t í-keng chûn-chāi.",
|
||||
"error.category_not_found": "Chit ê lūi-pia̍t bô chûn-chāi ah-sī bô sio̍k-tī lí.",
|
||||
"error.database_error": "Chu-liāu khò͘ ū m̄-tiō: %v.",
|
||||
"error.different_passwords": "Su-li̍p ê bi̍t-bé chit nn̄g pái bô kâng.",
|
||||
"error.duplicate_fever_username": "Fever ê kháu-chō miâ í-keng hō͘ lâng iōng khì--ah!",
|
||||
"error.duplicate_googlereader_username": "Google Reader ê kháu-chō miâ í-keng hō͘ lâng iōng khì--ah!",
|
||||
"error.duplicate_linked_account": "Chit ê beh kiat chòe-hé--ê í-keng seng hō͘ lâng kiat khì--ah!",
|
||||
"error.duplicated_feed": "Chit ê siau-sit lâi-goân í-keng chûn-chāi.",
|
||||
"error.empty_file": "Chit ê tóng-àn sī khang--ê.",
|
||||
"error.entries_per_page_invalid": "Ta̍k ia̍h ê siau-sit sò͘ ū būn-tôe.",
|
||||
"error.feed_already_exists": "Chit ê siau-sit lâi-goân í-keng chûn-chāi.",
|
||||
"error.feed_category_not_found": "Bô chit ê lūi-pia̍t ah-sī kóng bô sio̍k-tī chit ê sú-iōng-lâng.",
|
||||
"error.feed_format_not_detected": "Bōe līn chit ê siau-sit lâi-goân ê keh-sek: %v.",
|
||||
"error.feed_invalid_blocklist_rule": "Hong-só kui-chek bô-hāu.",
|
||||
"error.feed_invalid_keeplist_rule": "Pó-liû kui-chek bô-hāu.",
|
||||
"error.feed_mandatory_fields": "Tio̍h-ài su-lip bāng-chí kah lūi-pia̍t.",
|
||||
"error.feed_not_found": "Chhē bô chit ê siau-sit lâi-goân ah-sī bô sio̍k-tī lí",
|
||||
"error.feed_title_not_empty": "Beh tēng ê siau-sit lâi-goân ê piau-tôe bōe-sái sī khang--ê.",
|
||||
"error.feed_url_not_empty": "Beh tēng ê siau-sit lâi-goân bāng-chí bōe-sái sī khang--ê.",
|
||||
"error.fields_mandatory": "Tio̍h-ài kā chu-liāu lóng siá chê.",
|
||||
"error.http_bad_gateway": "Chit ê bāng-chām chit-má in-ūi gateway ū būn-tôe bô-hoat-tō͘ iōng, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
|
||||
"error.http_body_read": "Bô-hoat-tō͘ tha̍k HTTP body lōe-iông: %v。",
|
||||
"error.http_client_error": "HTTP kheh-hō͘ thâu ū m̄-tio̍h: %v.",
|
||||
"error.http_empty_response": "HTTP hôe-èng lōe-iông sī khang--ê, ū khó-lêng sī hit ê bāng-chām ū pó-hō͘ ki-chè.",
|
||||
"error.http_empty_response_body": "HTTP hôe-èng body sī khang--ê.",
|
||||
"error.http_forbidden": "Hō͘ kū-choa̍t chûn-chhú chit ê bāng-chām, ū khó-lêng chit ê bāng-chām ū pó-hō͘ ki-chè.",
|
||||
"error.http_gateway_timeout": "Tán chit ê bāng-chām ê hôe-èng í-keng chhiau-kè sî-kan, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
|
||||
"error.http_internal_server_error": "Chit ê bāng-chām ê su-hāu-khì in ka-kī ū būn-tôe, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
|
||||
"error.http_not_authorized": "Bô khoân chûn-chhú chit ê bāng-chām, chhiáⁿ kiám-cha kháu-chō miâ kah bi̍t-bé。",
|
||||
"error.http_resource_not_found": "Chhē bô chit ê liân-kiat, chhiáⁿ khak-līn bāng-chí kám ū chèng-khak.",
|
||||
"error.http_response_too_large": "HTTP hôe-èng siuⁿ tōa. Lí ē-sái tī choân-he̍k siat-tēng lāi kā siōng koân hān-tō͘ kái khah koân (ài têng khui su-hāu-khì)。",
|
||||
"error.http_service_unavailable": "Chit ê bāng-chām in-ūi in ka-kī lāi-pō͘ ū būn-tôe,m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
|
||||
"error.http_too_many_requests": "Miniflux tùi chit ê bāng-chām ê chhéng-kiû siuⁿ kè chōe, chhiáⁿ têng chhì-khòaⁿ-māi ah-sī tiâu-chéng thêng-sek siat-tēng.",
|
||||
"error.http_unexpected_status_code": "Chit ê bāng-chām chòe liáu chi̍t ê liāu-bōe-tio̍h ê HTTP chōng-thài bé: %d, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
|
||||
"error.invalid_default_home_page": "Ū-siat chú-ia̍h ū būn-tôe!",
|
||||
"error.invalid_display_mode": "Ū būn-tôe ê su-li̍p bô͘-sek.",
|
||||
"error.invalid_entry_direction": "Ū būn-tôe ê su-li̍p hong-hiòng.",
|
||||
"error.invalid_feed_url": "Beh tēng ê siau-sit lâi-goân ê bāng-chí ū būn-tôe.",
|
||||
"error.invalid_gesture_nav": "Chhiú-sè tō-lám ū būn-tôe.",
|
||||
"error.invalid_language": "Ū būn-tôe ê gú-giân.",
|
||||
"error.invalid_site_url": "Siau-sit lâi-goân ê bāng-chām ê bāng-chí ū būn-tôe.",
|
||||
"error.invalid_theme": "Ū būn-tôe ê chú-tôe.",
|
||||
"error.invalid_timezone": "Ū būn-tôe ê sî-khu.",
|
||||
"error.network_operation": "Miniflux bô-hoat-tō͘ liân kàu chit ê bāng-chām, ū khó-lêng sī bāng-lō͘ būn-tôe: %v.",
|
||||
"error.network_timeout": "Chit ê bāng-chām ê hôe-èng siuⁿ bān, chhéng-kiû chhiau-kè sî-kan: %v.",
|
||||
"error.password_min_length": "Chhiáⁿ chì-chió ài su-li̍p la̍k ê lī goân.",
|
||||
"error.pocket_access_token": "Bô-hoat-tō͘ ùi Pocket thê tio̍h access token",
|
||||
"error.pocket_request_token": "Bô-hoat-tō͘ ùi Pocket thê tio̍h request token",
|
||||
"error.settings_block_rule_fieldname_invalid": "Bô-hāu ê hong-só kui-chek: kui-chek #%d khiàm ū-hāu ê lân-ūi miâ (e-sai ê soán-hāng: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "Bô-hāu ê hong-só kui-chek: kui-chek #%d ê bô͘-sek m̄ sī ha̍p-hoat ê chiàⁿ-kui piáu-ta̍t sek",
|
||||
"error.settings_block_rule_regex_required": "Bô-hāu ê hong-só kui-chek: kui-chek #%d bô thê-kiong chiàⁿ-kui piáu-ta̍t sek",
|
||||
"error.settings_block_rule_separator_required": "Bô-hāu ê hong-só kui-chek: kui-chek #%d ê bô͘-sek tio̍h-ài iōng '=' keh khui.",
|
||||
"error.settings_invalid_domain_list": "Bāng-he̍k chheng-toaⁿ ū būn-tôe, chhiáⁿ iōng khang-keh keh khui bô kâng ê bāng-he̍k.",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Bô-hāu ê pó-liû kui-chek: kui-chek #%d khiàm ū-hāu ê lân-ūi miâ (e-sai ê soán-hāng: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "Bô-hāu ê pó-liû kui-chek: kui-chek #%d d ê bô͘-sek m̄ sī ha̍p-hoat ê chiàⁿ-kui piáu-ta̍t sek",
|
||||
"error.settings_keep_rule_regex_required": "Bô-hāu ê pó-liû kui-chek: kui-chek #%d bô thê-kiong chiàⁿ-kui piáu-ta̍t sek",
|
||||
"error.settings_keep_rule_separator_required": "Bô-hāu ê pó-liû kui-chek: kui-chek #%d ê bô͘-sek tio̍h-ài iōng '=' keh khui.",
|
||||
"error.settings_mandatory_fields": "Tio̍h-ài su-li̍p kháu-chō miâ, chú-tôe, gú-giân, sî-khu.",
|
||||
"error.settings_media_playback_rate_range": "Pàng ê sok-tō͘ chhiau-kè hoān-ûi",
|
||||
"error.settings_reading_speed_is_positive": "Tha̍k ê sok-tō͘ tio̍h-ài sī chiaⁿ chéng-sò͘",
|
||||
"error.site_url_not_empty": "Siau-sit lâi-goân ê bāng-chām ê bāng-chí bōe-sái sī khang--ê.",
|
||||
"error.subscription_not_found": "Chhē bōe tio̍h līm-hô tēng ê siau-sit lâi-goân",
|
||||
"error.title_required": "Tio̍h-ài su-li̍p piau-tôe.",
|
||||
"error.tls_error": "TLS m̄-tio̍h: %q。Nā-sī beh pàng-ba̍k TSL chèng-bêng, ē-sái tī siau-sit lâi-goân siat-tēng lāi thêng-tiong.",
|
||||
"error.unable_to_create_api_key": "Bô-hoat-tō͘ sin cheng-ka chit ê API só-sî.",
|
||||
"error.unable_to_create_category": "Bô-hoat-tō͘ sin cheng-ka chit ê lūi-pia̍t",
|
||||
"error.unable_to_create_user": "Bô-hoat-tō͘ sin cheng-ka chit ê sú-iōng-lâng",
|
||||
"error.unable_to_detect_rssbridge": "Sú-iōng RSS-Bridge sî chhē bô līm-hô siau-sit lâi-goân: %v.",
|
||||
"error.unable_to_parse_feed": "Bô-hoat-tō͘ kái-sek chit ê siau-sit lâi-goân: %v.",
|
||||
"error.unable_to_update_category": "Bô-hoat-tō͘ ōaⁿ-sin chit ê lūi-pia̍t",
|
||||
"error.unable_to_update_feed": "Bô-hoat-tō͘ ōaⁿ-sin chit ê siau-sit lâi-goân",
|
||||
"error.unable_to_update_user": "Bô-hoat-tō͘ ōaⁿ-sin chit ê sú-iōng-lâng",
|
||||
"error.unlink_account_without_password": "Lí it-tēng ài siat-tēng bi̍t-bé, bô lí ē bô-hoat-tō͘ koh teng-lo̍k.",
|
||||
"error.user_already_exists": "Chit ê sú-iōng-lâng í-keng chûn-chāi.",
|
||||
"error.user_mandatory_fields": "Tio̍h-ài su-li̍p kháu-chō miâ",
|
||||
"form.api_key.label.description": "API só-sîkhan-á",
|
||||
"form.category.hide_globally": "Mài hián-sī siau-sit tī choân-he̍k ah-bōe tha̍k lia̍t-pió lāi",
|
||||
"form.category.label.title": "Piau-tôe",
|
||||
"form.feed.fieldset.general": "Thong-iōng",
|
||||
"form.feed.fieldset.integration": "Tē-saⁿ hong ho̍k-bū",
|
||||
"form.feed.fieldset.network_settings": "Bāng-lō͘ siat-tēng",
|
||||
"form.feed.fieldset.rules": "Kui-chek",
|
||||
"form.feed.label.allow_self_signed_certificates": "ún-chún chū chhiam ah-sī bô-hāu ê pîn-chèng",
|
||||
"form.feed.label.apprise_service_urls": "Sú-iōng tō͘-tiám keh khui ê Apprise ho̍k-bū bāng-chí lia̍t-pió",
|
||||
"form.feed.label.blocklist_rules": "Hong-só kui-chek",
|
||||
"form.feed.label.category": "lūi-pia̍t",
|
||||
"form.feed.label.cookie": "Siat-tēng Cookies",
|
||||
"form.feed.label.crawler": "Lia̍h goân-tóe lōe-iông",
|
||||
"form.feed.label.description": "Biâu-su̍t",
|
||||
"form.feed.label.disable_http2": "Thêng iōng HTTP/2 pī-bián chéng-thâu-á-hûn tui-chong",
|
||||
"form.feed.label.disabled": "Mài tha̍k chit ê siau-sit lâi-goân ê sin siau-sit",
|
||||
"form.feed.label.feed_password": "Siau-sit lâi-goân bi̍t-bé",
|
||||
"form.feed.label.feed_url": "Siau-sit lâi-goân bāng-chí",
|
||||
"form.feed.label.feed_username": "Siau-sit lâi-goân kháu-chō miâ",
|
||||
"form.feed.label.fetch_via_proxy": "Thàu-kè tāi-lí lia̍h",
|
||||
"form.feed.label.hide_globally": "Tī choân-he̍k ah-bōe tha̍k--ê lia̍t-pió am-khàm siau-sit",
|
||||
"form.feed.label.ignore_http_cache": "Pàng-ba̍k HTTP cache",
|
||||
"form.feed.label.keeplist_rules": "Pó-liû kui-chek",
|
||||
"form.feed.label.no_media_player": "Bô mûi-thé hòng-sàng khì (im-sìn, sī-sìn)",
|
||||
"form.feed.label.ntfy_activate": "Thui-sàng siau-sit khì ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Ntfy ū-siat iu-sian sūn-sū",
|
||||
"form.feed.label.ntfy_high_priority": "Ntfy koân iu-sian sūn-sū",
|
||||
"form.feed.label.ntfy_low_priority": "Ntfy kē iu-sian sūn-sū",
|
||||
"form.feed.label.ntfy_max_priority": "Ntfy siōng koân iu-sian sūn-sū",
|
||||
"form.feed.label.ntfy_min_priority": "Ntfy siōng kē iu-sian sūn-sū",
|
||||
"form.feed.label.ntfy_priority": "Ntfy iu-sian sūn-sū",
|
||||
"form.feed.label.ntfy_topic": "Ntfy topic (soán thiⁿ)",
|
||||
"form.feed.label.pushover_activate": "Push entries to Pushover",
|
||||
"form.feed.label.pushover_default_priority": "Default priority",
|
||||
"form.feed.label.pushover_high_priority": "High priority",
|
||||
"form.feed.label.pushover_low_priority": "Low priority",
|
||||
"form.feed.label.pushover_max_priority": "Max priority",
|
||||
"form.feed.label.pushover_min_priority": "Minimal priority",
|
||||
"form.feed.label.pushover_priority": "Pushover message priority",
|
||||
"form.feed.label.rewrite_rules": "Têng siá kui-chek",
|
||||
"form.feed.label.scraper_rules": "Lia̍h ê kui-chek",
|
||||
"form.feed.label.site_url": "Bāng-chām bāng-chí",
|
||||
"form.feed.label.title": "Piau-tôe",
|
||||
"form.feed.label.urlrewrite_rules": "Bāng-chí têng siá kui-chek",
|
||||
"form.feed.label.user_agent": "Ngī kái sú-iōng-lâng tāi-lí",
|
||||
"form.feed.label.webhook_url": "Ngī kái webhook bāng-chí",
|
||||
"form.import.label.file": "OPML tóng-àn",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.apprise_activate": "Thui sàng siau-sit khì Apprise",
|
||||
"form.integration.apprise_services_url": "Iōng tō͘-tiám keh khui ê Apprise ho̍k-bū bāng-chí lia̍t-pió",
|
||||
"form.integration.apprise_url": "Apprise API bāng-chí",
|
||||
"form.integration.betula_activate": "Pó-chûn siau-sit kàu Betula",
|
||||
"form.integration.betula_token": "Betula Token",
|
||||
"form.integration.betula_url": "Betula su-hāu-khì bāng-chí",
|
||||
"form.integration.cubox_activate": "Pó-chûn siau-sit khì Cubox",
|
||||
"form.integration.cubox_api_link": "Cubox API liân-kiat",
|
||||
"form.integration.discord_activate": "Thui-sàng siau-sit kàu Discord",
|
||||
"form.integration.discord_webhook_link": "Discord Webhook liân-kiat",
|
||||
"form.integration.espial_activate": "Pó-chûn siau-sit kàu Espial",
|
||||
"form.integration.espial_api_key": "Espial API só-sî",
|
||||
"form.integration.espial_endpoint": "Espial API thâu",
|
||||
"form.integration.espial_tags": "Espial khan-á",
|
||||
"form.integration.fever_activate": "Khai-sí iōng Fever API",
|
||||
"form.integration.fever_endpoint": "Fever API thâu",
|
||||
"form.integration.fever_password": "Fever bi̍t-bé",
|
||||
"form.integration.fever_username": "Fever kháu-chō miâ",
|
||||
"form.integration.googlereader_activate": "Khai-sí iōng Google Reader API",
|
||||
"form.integration.googlereader_endpoint": "Google Reader API thâu:",
|
||||
"form.integration.googlereader_password": "Google Reader bi̍t-bé",
|
||||
"form.integration.googlereader_username": "Google Reader Kháu-chō miâ",
|
||||
"form.integration.instapaper_activate": "Pó-chûn siau-sit kàu Instapaper",
|
||||
"form.integration.instapaper_password": "Instapaper bi̍t-bé",
|
||||
"form.integration.instapaper_username": "Instapaper Kháu-chō miâ",
|
||||
"form.integration.linkace_activate": "Pó-chûn siau-sit kàu LinkAce",
|
||||
"form.integration.linkace_api_key": "LinkAce API só-sî",
|
||||
"form.integration.linkace_check_disabled": "Thêng iōng liân-kiat kiám-cha",
|
||||
"form.integration.linkace_endpoint": "LinkAce API thâu",
|
||||
"form.integration.linkace_is_private": "Chù chòe su-lîn ê liân-kiat",
|
||||
"form.integration.linkace_tags": "LinkAce khan-á",
|
||||
"form.integration.linkding_activate": "Pó-chûn siau-sit kàu Linkding",
|
||||
"form.integration.linkding_api_key": "Linkding API só-sî",
|
||||
"form.integration.linkding_bookmark": "Chù chòe ah-bōe tha̍k",
|
||||
"form.integration.linkding_endpoint": "Linkding API thâu",
|
||||
"form.integration.linkding_tags": "Linkding khan-á",
|
||||
"form.integration.linkwarden_activate": "Pó-chûn siau-sit kàu Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Linkwarden API só-sî",
|
||||
"form.integration.linkwarden_endpoint": "Linkwarden API thâu",
|
||||
"form.integration.matrix_bot_activate": "Thui-sàng siau-sit kàu Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix pâng-keng ID",
|
||||
"form.integration.matrix_bot_password": "Matrix bi̍t-bé",
|
||||
"form.integration.matrix_bot_url": "Matrix su-hāu-khìbāng-chí",
|
||||
"form.integration.matrix_bot_user": "Matrix kháu-chō miâ",
|
||||
"form.integration.notion_activate": "Pó-chûn siau-sit kàu Notion",
|
||||
"form.integration.notion_page_id": "Notion Page ID",
|
||||
"form.integration.notion_token": "Notion Secret Token",
|
||||
"form.integration.ntfy_activate": "Thui-sàng siau-sit kàu Ntfy",
|
||||
"form.integration.ntfy_api_token": "Ntfy API só-sî (soán thiⁿ)",
|
||||
"form.integration.ntfy_icon_url": "Ntfy Icon bāng-chí (soán thiⁿ)",
|
||||
"form.integration.ntfy_internal_links": "Tiám ê sî-chūn iōng lāi-pō͘ liân-kiat (soán thiⁿ)",
|
||||
"form.integration.ntfy_password": "Ntfy bi̍t-bé (soán thiⁿ)",
|
||||
"form.integration.ntfy_topic": "Ntfy topic (chhī-liāu nā bô siat-tēng, tiō iōng ī-siat-ti̍t)",
|
||||
"form.integration.ntfy_url": "Ntfy bāng-chí (soán thiⁿ, ū-siat sī ntfy.sh)",
|
||||
"form.integration.ntfy_username": "Ntfy kháu-chō miâ (soán thiⁿ)",
|
||||
"form.integration.nunux_keeper_activate": "Pó-chûn siau-sit kàu Nunux Keeper",
|
||||
"form.integration.nunux_keeper_api_key": "Nunux Keeper API só-sî",
|
||||
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API thâu",
|
||||
"form.integration.omnivore_activate": "Pó-chûn siau-sit kàu Omnivore",
|
||||
"form.integration.omnivore_api_key": "Omnivore API só-sî",
|
||||
"form.integration.omnivore_url": "Omnivore API thâu",
|
||||
"form.integration.pinboard_activate": "Pó-chûn siau-sit kàu Pinboard",
|
||||
"form.integration.pinboard_bookmark": "Chù chòe ah-bōe tha̍k",
|
||||
"form.integration.pinboard_tags": "Pinboard khan-á",
|
||||
"form.integration.pinboard_token": "Pinboard API Token",
|
||||
"form.integration.pocket_access_token": "Pocket token",
|
||||
"form.integration.pocket_activate": "Pó-chûn siau-sit kàu Pocket",
|
||||
"form.integration.pocket_connect_link": "Kah Pocket kháu-chō kiat chòe-hé",
|
||||
"form.integration.pocket_consumer_key": "Pocket sú-iōng-lâng só-sî",
|
||||
"form.integration.pushover_activate": "Push entries to Pushover",
|
||||
"form.integration.pushover_device": "Pushover device (optional)",
|
||||
"form.integration.pushover_prefix": "Pushover URL prefix (optional)",
|
||||
"form.integration.pushover_token": "Pushover application API token",
|
||||
"form.integration.pushover_user": "Pushover user key",
|
||||
"form.integration.raindrop_activate": "Pó-chûn siau-sit kàu Raindrop",
|
||||
"form.integration.raindrop_collection_id": "Collection ID",
|
||||
"form.integration.raindrop_tags": "khan-á (iōng tō͘-tiám keh khui)",
|
||||
"form.integration.raindrop_token": "Raindrop Acess Token",
|
||||
"form.integration.readeck_activate": "Pó-chûn siau-sit kàu Readeck",
|
||||
"form.integration.readeck_api_key": "Readeck API só-sî",
|
||||
"form.integration.readeck_endpoint": "Readeck API thâu",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Kan-na thoân bāng-chí (m̄ sī oân-chéng ê lōe-iông)",
|
||||
"form.integration.readwise_activate": "Pó-chûn siau-sit kàu Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Readwise Reader Acess Token",
|
||||
"form.integration.readwise_api_key_link": "Chhú-tek lí ê Readwise Acess Token",
|
||||
"form.integration.rssbridge_activate": "Sin cheng-ka siau-sit lâi-goân ê sî tio̍h RSS-Bridge",
|
||||
"form.integration.rssbridge_url": "RSS-Bridge su-hāu-khì的bāng-chí",
|
||||
"form.integration.shaarli_activate": "Pó-chûn siau-sit kàu Shaarli",
|
||||
"form.integration.shaarli_api_secret": "Shaarli API só-sî",
|
||||
"form.integration.shaarli_endpoint": "Shaarli bāng-chí",
|
||||
"form.integration.shiori_activate": "Pó-chûn siau-sit kàu Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori API thâu",
|
||||
"form.integration.shiori_password": "Shiori bi̍t-bé",
|
||||
"form.integration.shiori_username": "Shiori kháu-chō miâ",
|
||||
"form.integration.slack_activate": "Thui-sàng siau-sit kàu Slack",
|
||||
"form.integration.slack_webhook_link": "Slack Webhook liân-kiat",
|
||||
"form.integration.telegram_bot_activate": "Thui-sàng siau-sit kàu Telegram",
|
||||
"form.integration.telegram_bot_disable_buttons": "Mài hián-sī khai-koan",
|
||||
"form.integration.telegram_bot_disable_notification": "Têng iōng thong-ti",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Thêng iōng bāng-ia̍h ū-lám",
|
||||
"form.integration.telegram_bot_token": "Bot Token",
|
||||
"form.integration.telegram_chat_id": "Chat ID",
|
||||
"form.integration.telegram_topic_id": "Topic ID",
|
||||
"form.integration.wallabag_activate": "Pó-chûn siau-sit kàu Wallabag",
|
||||
"form.integration.wallabag_client_id": "Wallabag kheh-hō͘ thâu ID",
|
||||
"form.integration.wallabag_client_secret": "Wallabag kheh-hō͘ thâu só-sî",
|
||||
"form.integration.wallabag_endpoint": "Wallabag Base URL",
|
||||
"form.integration.wallabag_only_url": "Kan-na thoân bāng-chí (m̄ sī oân-chéng ê lōe-iông)",
|
||||
"form.integration.wallabag_password": "Wallabag bi̍t-bé",
|
||||
"form.integration.wallabag_username": "Wallabag kháu-chō miâ",
|
||||
"form.integration.webhook_activate": "Khai-sí iōng Webhooks",
|
||||
"form.integration.webhook_secret": "Webhooks Secret",
|
||||
"form.integration.webhook_url": "Default Webhook bāng-chí",
|
||||
"form.prefs.fieldset.application_settings": "Èng-iōng thêng-sek siat-tēng",
|
||||
"form.prefs.fieldset.authentication_settings": "Sú-iōng-lâng giām-chèng siat-tēng",
|
||||
"form.prefs.fieldset.global_feed_settings": "Choân-he̍k siau-sit lâi-goân siat-tēng",
|
||||
"form.prefs.fieldset.reader_settings": "Ia̍t-tha̍k khì siat-tēng",
|
||||
"form.prefs.help.external_font_hosts": "Iōng khang-keh keh khui ún-chún ê gōa-pō͘ lī-hêng lâi-goân. Phì-lû \"fonts.gstatic.com fonts.googleapis.com\"",
|
||||
"form.prefs.label.categories_sorting_order": "Lūi-pia̍t hián-sī sūn-sū",
|
||||
"form.prefs.label.cjk_reading_speed": "Tiong-bûn, Hân-bûn, Li̍t-bûn tha̍k ê sok-tō͘ (múi hun-cheng ē-sái tha̍k kúi ê lī-goân)",
|
||||
"form.prefs.label.custom_css": "Chū tēng ê CSS",
|
||||
"form.prefs.label.custom_js": "Chū tēng ê JavaScript",
|
||||
"form.prefs.label.default_home_page": "Ū-siat chú-ia̍h",
|
||||
"form.prefs.label.default_reading_speed": "Kî-thaⁿ gú-giân tha̍k ê sok-tō͘ (múi hun-cheng ē-sái tha̍k kúi ê lī)",
|
||||
"form.prefs.label.display_mode": "Chiām-chìn sek bāng-lō͘ èng-iōng theng-sek (PWA) ê hián-sī bô͘-sek",
|
||||
"form.prefs.label.entries_per_page": "Ta̍k ia̍h siau-sit sò͘",
|
||||
"form.prefs.label.entry_order": "Siau-sit hián-sī sūn-sū ê i-kù",
|
||||
"form.prefs.label.entry_sorting": "Siau-sit sūn-sū",
|
||||
"form.prefs.label.entry_swipe": "Ē-sái tī chhiok-khòng sek êng-bō͘ ùi siau-sit iōng thoa tāng chhau-chok",
|
||||
"form.prefs.label.external_font_hosts": "Gōa-pō͘ lī-hêng lâi-goân",
|
||||
"form.prefs.label.gesture_nav": "Tī siau-sit kan sóa-ūi ê chhiú-sè",
|
||||
"form.prefs.label.keyboard_shortcuts": "Ē-sái iōng khí-pôaⁿ khoài-sok khí",
|
||||
"form.prefs.label.language": "Gú-giân",
|
||||
"form.prefs.label.mark_read_manually": "Ka-kī chhau-chok kám beh chù chòe tha̍k kè",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Kan-na tī im-sìn, sī-sìn hòng-sàng kàu 90%% ê si-chun chù chòe tha̍k kè",
|
||||
"form.prefs.label.mark_read_on_view": "Phah khui ê sî-chūn sūn-sòa kā siau-sit chù chòe tha̍k kè",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Phah khui ê sî-chūn sūn-sòa kā siau-sit chù chòe tha̍k kè, m̄-koh nā-sī im-sìn, sī-sìn tio̍h tī hòng-sàng kàu 90%% ê si-chun chiah lâi chù",
|
||||
"form.prefs.label.media_playback_rate": "Im-sìn, sī-sìn pàng ê sok-tō͘",
|
||||
"form.prefs.label.show_reading_time": "Hián-sī siau-sit àn-sǹg ài gōa-kú lâi tha̍k",
|
||||
"form.prefs.label.theme": "Chú-tôe",
|
||||
"form.prefs.label.timezone": "Sî-khu",
|
||||
"form.prefs.select.alphabetical": "Chiàu lī-bú pâi",
|
||||
"form.prefs.select.browser": "Tī iû-lám khì phah khui",
|
||||
"form.prefs.select.created_time": "Siau-sit kiàn-li̍p sî-kan",
|
||||
"form.prefs.select.fullscreen": "Choân êng-bō͘",
|
||||
"form.prefs.select.minimal_ui": "Siōng iông-chhun--ê",
|
||||
"form.prefs.select.none": "Bô",
|
||||
"form.prefs.select.older_first": "Ùi kū--ê khai-sí pâi",
|
||||
"form.prefs.select.publish_time": "Siau-sit hoat-pò͘ sî-kan",
|
||||
"form.prefs.select.recent_first": "Ùi sin--ê khai-sí pâi",
|
||||
"form.prefs.select.standalone": "To̍k-li̍p--ê",
|
||||
"form.prefs.select.swipe": "Iōng thoa--ê",
|
||||
"form.prefs.select.tap": "Tiám nn̄g pái",
|
||||
"form.prefs.select.unread_count": "Ah-bōe tha̍k ê sò͘-liōng",
|
||||
"form.submit.loading": "Tng leh chip-hêng…",
|
||||
"form.submit.saving": "Tng leh pó-chûn…",
|
||||
"form.user.label.admin": "Koán-lí-lâng",
|
||||
"form.user.label.confirmation": "Koh su-li̍p chi̍t pái bi̍t-bé",
|
||||
"form.user.label.password": "Bi̍t-bé",
|
||||
"form.user.label.username": "Kháu-chō miâ",
|
||||
"menu.about": "Iú-koan",
|
||||
"menu.add_feed": "Sin cheng-ka siau-sit lâi-goân",
|
||||
"menu.add_user": "Sin cheng-ka sú-iōng-lâng",
|
||||
"menu.api_keys": "API só-sî",
|
||||
"menu.categories": "Lūi-pia̍t",
|
||||
"menu.create_api_key": "Sin cheng-ka chi̍t ê API só-sî",
|
||||
"menu.create_category": "Sin cheng-ka lūi-pia̍t",
|
||||
"menu.edit_category": "Pian-chi̍p",
|
||||
"menu.edit_feed": "Pian-chi̍p",
|
||||
"menu.export": "Hōe--chhut",
|
||||
"menu.feed_entries": "Bûn-chiong",
|
||||
"menu.feeds": "Siau-sit lâi-goân",
|
||||
"menu.flush_history": "Hìⁿ-sak kì-lo̍k",
|
||||
"menu.history": "Kì-lo̍k",
|
||||
"menu.home_page": "Siú ia̍h",
|
||||
"menu.import": "Hōe--li̍p",
|
||||
"menu.integrations": "Chéng-ha̍p",
|
||||
"menu.logout": "Teng-chhut",
|
||||
"menu.mark_all_as_read": "Choân-pō͘ chù chòe tha̍k kè",
|
||||
"menu.mark_page_as_read": "Kā chit ia̍h--ê lóng chù chòe tha̍k kè",
|
||||
"menu.preferences": "Siat-tēng",
|
||||
"menu.refresh_all_feeds": "Tī pōe-āu têng lia̍h só͘-ū ê siau-sit lâi-goân",
|
||||
"menu.refresh_feed": "Têng lia̍h",
|
||||
"menu.search": "Chhiau-chhē",
|
||||
"menu.sessions": "Ū teng-lo̍k--ê",
|
||||
"menu.settings": "Siat-tēng",
|
||||
"menu.shared_entries": "Hun-hióng kè ê siau-sit",
|
||||
"menu.show_all_entries": "Hián-sī só͘-ū ê siau-sit",
|
||||
"menu.show_only_starred_entries": "Kan-na hián-sī siu-chông ê siau-sit",
|
||||
"menu.show_only_unread_entries": "Kan-na hián-sī ah-bōe tha̍k kè ê siau-sit",
|
||||
"menu.starred": "Siu-chông",
|
||||
"menu.title": "Tō-lám",
|
||||
"menu.unread": "Ah-bōe tha̍k",
|
||||
"menu.users": "Sú-iōng-lâng",
|
||||
"page.about.author": "Chok-chiá: ",
|
||||
"page.about.build_date": "Kiàn-tì li̍t-kî:",
|
||||
"page.about.credits": "Pán-koân",
|
||||
"page.about.db_usage": "Database size:",
|
||||
"page.about.global_config_options": "Choân-he̍k siat-tēng soán-hāng",
|
||||
"page.about.go_version": "Go pán-pún:",
|
||||
"page.about.license": "Pàng-koân:",
|
||||
"page.about.postgres_version": "Postgres pán-pún:",
|
||||
"page.about.title": "Iú-koan",
|
||||
"page.about.version": "Pán-pún:",
|
||||
"page.add_feed.choose_feed": "Soán-te̍k chi̍t ê Siau-sit lâi-goân",
|
||||
"page.add_feed.label.url": "Bāng-chí",
|
||||
"page.add_feed.legend.advanced_options": "Chìn-kai soán-hāng",
|
||||
"page.add_feed.no_category": "Ah bô lūi-pia̍t, chì-chió ài ū chi̍t ê",
|
||||
"page.add_feed.submit": "Chhē Siau-sit lâi-goân",
|
||||
"page.add_feed.title": "Sin cheng-ka Siau-sit lâi-goân",
|
||||
"page.api_keys.never_used": "Bô iōng kè",
|
||||
"page.api_keys.table.actions": "Chhau-chok",
|
||||
"page.api_keys.table.created_at": "Kiàn-tì li̍t-kî",
|
||||
"page.api_keys.table.description": "Biâu-su̍t",
|
||||
"page.api_keys.table.last_used_at": "Siōng-bóe pái sú-iōng",
|
||||
"page.api_keys.table.token": "Só-sî",
|
||||
"page.api_keys.title": "API só-sî",
|
||||
"page.categories.entries": "Siau-sit",
|
||||
"page.categories.feed_count": [
|
||||
"Ū %d ê Siau-sit lâi-goân"
|
||||
],
|
||||
"page.categories.feeds": "Siau-sit lâi-goân",
|
||||
"page.categories.no_feed": "Ah-bô siau-sit lâi-goân",
|
||||
"page.categories.title": "Lūi-pia̍t",
|
||||
"page.categories_count": [
|
||||
"%d ê lūi-pia̍t"
|
||||
],
|
||||
"page.category_label": "Lūi-pia̍t: %s",
|
||||
"page.edit_category.title": "Pian-chi̍p lūi-pia̍t: %s",
|
||||
"page.edit_feed.etag_header": "ETag piau-thâu:",
|
||||
"page.edit_feed.last_check": "Siōng-bóe pái kiám-cha sî-kan",
|
||||
"page.edit_feed.last_modified_header": "Siōng-bóe pái siu-kái piau-thâu:",
|
||||
"page.edit_feed.last_parsing_error": "Siōng-bóe pái kái-sek m̄-tio̍h",
|
||||
"page.edit_feed.no_header": "Bô",
|
||||
"page.edit_feed.title": "Pian-chi̍p Siau-sit lâi-goân: %s",
|
||||
"page.edit_user.title": "pian-chi̍p sú-iōng-lâng: %s",
|
||||
"page.entry.attachments": "Hù-kiāⁿ",
|
||||
"page.feeds.error_count": [
|
||||
"%d ê m̄-tio̍h"
|
||||
],
|
||||
"page.feeds.last_check": "Siōng-bóe kiám-cha sî-kan:",
|
||||
"page.feeds.next_check": "Āu-pái kiám-cha sî-kan:",
|
||||
"page.feeds.read_counter": "Tha̍k kè--ê siau-sit sò͘",
|
||||
"page.feeds.title": "Siau-sit lâi-goân",
|
||||
"page.history.title": "Kì-lo̍k",
|
||||
"page.import.title": "Hōe-li̍p",
|
||||
"page.integration.bookmarklet": "Chheh-chhiam ke-si",
|
||||
"page.integration.bookmarklet.help": "Lí ē-sái iōng chit ê te̍k-pia̍t ê chheh-chhiam ti̍t-chiap tēng bāng-ia̍h ê siau-sit",
|
||||
"page.integration.bookmarklet.instructions": "Kā chit ê liân-kiat thoa khì iû-lám khì ê chheh-chhiam lân",
|
||||
"page.integration.bookmarklet.name": "Siu-chông Miniflux",
|
||||
"page.integration.miniflux_api": "Miniflux API",
|
||||
"page.integration.miniflux_api_endpoint": "API thâu",
|
||||
"page.integration.miniflux_api_password": "Bi̍t-bé",
|
||||
"page.integration.miniflux_api_password_value": "Lí ê kháu-chō ê bi̍t-bé",
|
||||
"page.integration.miniflux_api_username": "Kháu-chō miâ",
|
||||
"page.integrations.title": "Chéng-ha̍p",
|
||||
"page.keyboard_shortcuts.close_modal": "Kìm tiāu tùi-ōe thang",
|
||||
"page.keyboard_shortcuts.download_content": "Liah goân-tóe ê siau-sit lōe-iông",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "Sóa khì thōng ē-kha ê siau-sit",
|
||||
"page.keyboard_shortcuts.go_to_categories": "Phah khui lūi-pia̍t ia̍h",
|
||||
"page.keyboard_shortcuts.go_to_feed": "Khì siau-sit lâi-goân",
|
||||
"page.keyboard_shortcuts.go_to_feeds": "Phah khui siau-sit lâi-goân ia̍h",
|
||||
"page.keyboard_shortcuts.go_to_history": "Phah khui kì-lo̍k ia̍h",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "Āu-chi̍t ê siau-sit",
|
||||
"page.keyboard_shortcuts.go_to_next_page": "Āu-chi̍t ia̍h",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "Téng-chi̍t ê siau-sit",
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "Téng-chi̍t ia̍h",
|
||||
"page.keyboard_shortcuts.go_to_search": "Phah khui chhiau-chhē ia̍h",
|
||||
"page.keyboard_shortcuts.go_to_settings": "Phah khui siat-tēng ia̍h",
|
||||
"page.keyboard_shortcuts.go_to_starred": "Phah khui siu-chông--ê ia̍h",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "Sóa khì thōng téng-koân ê siau-sit",
|
||||
"page.keyboard_shortcuts.go_to_unread": "Phah khui ah-bōe tha̍k--ê ia̍h",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Kā chit ia̍h--ê lóng chù chòe tha̍k--kè",
|
||||
"page.keyboard_shortcuts.open_comments": "Phah khui hôe-èng liân-kiat",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Tī chit-má ê hun-ia̍h phah khui hôe-èng liân-kiat",
|
||||
"page.keyboard_shortcuts.open_item": "Phah khui soán-te̍k ê siau-sit",
|
||||
"page.keyboard_shortcuts.open_original": "Phah khui siau-sit goân-tóe ê liân-kiat",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "Tī chit-má ê hun-ia̍h phah khui siau-sit goân-tóe ê liân-kiat",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Tī pōe-āu ōaⁿ-sin siau-sit lâi-goân",
|
||||
"page.keyboard_shortcuts.remove_feed": "Thâi tiāu siau-sit lâi-goân",
|
||||
"page.keyboard_shortcuts.save_article": "Pó-chûn siau-sit",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "Sóa khì bāng-ia̍h siōng téng-koân",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Hián-sī khoài-sok khí",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "Chhau-chok",
|
||||
"page.keyboard_shortcuts.subtitle.items": "Bûn-chiong tō-lám",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Ia̍h bīn tō-lám",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Hun lân tō-lám",
|
||||
"page.keyboard_shortcuts.title": "Khoài-sok khí",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Chhet-li̍p siu-chông chōng-thài",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Chhet-li̍p thián khui kah siu-ha̍p siau-sit hù-kiāⁿ ê chōng-thài",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Chhet-li̍p tha̍k--kè, ah-bōe tha̍k ê chōng-thài, koh chiau-tiám tī āu-chi̍t--ê",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Chhet-li̍p tha̍k--kè, ah-bōe tha̍k ê chōng-thài, koh chiau-tiám tī téng-chi̍t--ê",
|
||||
"page.login.google_signin": "Sú-iōng Google teng-lo̍k",
|
||||
"page.login.oidc_signin": "Sú-iōng %s teng-lo̍k",
|
||||
"page.login.title": "teng-lo̍k",
|
||||
"page.login.webauthn_login": "Sú-iōng bi̍t-bé teng-lo̍k",
|
||||
"page.login.webauthn_login.error": "Bô-hoat-tō͘ iōng bi̍t-bé teng-lo̍k",
|
||||
"page.login.webauthn_login.help": "Sú-iōng an-choân só-sî teng-lo̍k ê sî-chūn, chhiáⁿ su-li̍p kháu-chō miâ. Nā-sī iōng thang chhiau-chhē ê Passkey (discoverable credentials) tio̍h bián.",
|
||||
"page.new_api_key.title": "Sin ê API só-sî",
|
||||
"page.new_category.title": "Sin lūi-pia̍t",
|
||||
"page.new_user.title": "Sin sú-iōng-lâng",
|
||||
"page.offline.message": "Lí í-keng lî-sòaⁿ",
|
||||
"page.offline.refresh_page": "Chhì-khòaⁿ-māi têng tha̍k bāng-ia̍h",
|
||||
"page.offline.title": "Lî-sòaⁿ bô͘-sek",
|
||||
"page.read_entry_count": [
|
||||
"%d ê tha̍k kè ê siau-sit"
|
||||
],
|
||||
"page.search.title": "Chhiau-chhē kiat-kó",
|
||||
"page.sessions.table.actions": "Chhau-chok",
|
||||
"page.sessions.table.current_session": "Chit-má teng-lo̍k--ê",
|
||||
"page.sessions.table.date": "Li̍t-kî",
|
||||
"page.sessions.table.ip": "IP tōe-chí",
|
||||
"page.sessions.table.user_agent": "Sú-iōng-lâng tāi-lí",
|
||||
"page.sessions.title": "Ū teng-lo̍k--ê",
|
||||
"page.settings.link_google_account": "Kah góa ê Google kháu-chō kiat chòe-hé",
|
||||
"page.settings.link_oidc_account": "Kah góa ê %s kháu-chō kiat chòe-hé",
|
||||
"page.settings.title": "Siat-tēng",
|
||||
"page.settings.unlink_google_account": "Phah khui kah góa ê Google kháu-chō ê kiat",
|
||||
"page.settings.unlink_oidc_account": "Phah khui kah góa ê %s kháu-chō ê kiat",
|
||||
"page.settings.webauthn.actions": "Chhau-chok",
|
||||
"page.settings.webauthn.added_on": "Sin cheng-ka ê sî-kan",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Thâi tiāu %d ê Passkey"
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Siōng-bóe pái sú-iōng sî-kan",
|
||||
"page.settings.webauthn.passkey_name": "Passkey miâ",
|
||||
"page.settings.webauthn.passkeys": "Passkeys",
|
||||
"page.settings.webauthn.register": "Chù-chheh Passkey",
|
||||
"page.settings.webauthn.register.error": "Bô-hoat-tō͘ chù-chheh Passkey",
|
||||
"page.shared_entries.title": "Hun-hióng kè ê siau-sit",
|
||||
"page.shared_entries_count": [
|
||||
"Í-keng hun-hióng %d ê siau-sit"
|
||||
],
|
||||
"page.starred.title": "Siu-chông",
|
||||
"page.starred_entry_count": [
|
||||
"%d ê siu-chông ê siau-sit"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"Lóng-chóng %d ê siau-sit"
|
||||
],
|
||||
"page.unread.title": "Ah-bōe tha̍k",
|
||||
"page.unread_entry_count": [
|
||||
"%d ê siau-sit ah-bōe tha̍k"
|
||||
],
|
||||
"page.users.actions": "chhau-chok",
|
||||
"page.users.admin.no": "Hóⁿ",
|
||||
"page.users.admin.yes": "Sī",
|
||||
"page.users.is_admin": "Koán-lí-lâng",
|
||||
"page.users.last_login": "Siōng-bóe pái teng-lo̍k",
|
||||
"page.users.never_logged": "Chū-lâi bô teng-lo̍k kè",
|
||||
"page.users.title": "Sú-iōng-lâng",
|
||||
"page.users.username": "Sú-iōng-lâng miâ",
|
||||
"page.webauthn_rename.title": "Tiông-sin hō͘ miâ Passkey",
|
||||
"pagination.first": "Thâu-chi̍t ia̍h",
|
||||
"pagination.last": "Siōng-bóe ia̍h",
|
||||
"pagination.next": "Āu-chi̍t ia̍h",
|
||||
"pagination.previous": "Téng-chi̍t ia̍h",
|
||||
"search.label": "Chhiau-chhē",
|
||||
"search.placeholder": "Chhiau-chhē...",
|
||||
"search.submit": "Chhiau-chhē",
|
||||
"skip_to_content": "Sóa kah chú-iàu ê lōe-iông",
|
||||
"time_elapsed.days": [
|
||||
"%d kang chêng"
|
||||
],
|
||||
"time_elapsed.hours": [
|
||||
"%d tiám-cheng chêng"
|
||||
],
|
||||
"time_elapsed.minutes": [
|
||||
"%d hun-cheng chêng"
|
||||
],
|
||||
"time_elapsed.months": [
|
||||
"%d kò ge̍h chêng"
|
||||
],
|
||||
"time_elapsed.not_yet": "ah-bōe",
|
||||
"time_elapsed.now": "tú-chiah",
|
||||
"time_elapsed.weeks": [
|
||||
"%d lé-pài chêng"
|
||||
],
|
||||
"time_elapsed.years": [
|
||||
"%d nî chêng"
|
||||
],
|
||||
"time_elapsed.yesterday": "cha-hng",
|
||||
"tooltip.keyboard_shortcuts": "Khoài-sok khí:%s",
|
||||
"tooltip.logged_user": "Chit-má teng-lo̍k--ê: %s"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,623 @@
|
||||
{
|
||||
"action.cancel": "abandon",
|
||||
"action.download": "Descărcare",
|
||||
"action.edit": "Editare",
|
||||
"action.home_screen": "Adaugă pe ecranul principal",
|
||||
"action.import": "Importă",
|
||||
"action.login": "Autentificare",
|
||||
"action.or": "sau",
|
||||
"action.remove": "Elimină",
|
||||
"action.remove_feed": "Elimină acest flux",
|
||||
"action.save": "Salvează",
|
||||
"action.subscribe": "Abonează-te",
|
||||
"action.update": "Actualizare",
|
||||
"alert.account_linked": "Contul dvs. extern este atașat!",
|
||||
"alert.account_unlinked": "Am decuplat contul dvs. extern!",
|
||||
"alert.background_feed_refresh": "Toate fluxurile sunt actualizate în fundal. Puteți să continuați utilizarea Miniflux în timp ce procesul rulează.",
|
||||
"alert.feed_error": "Este o problemă cu acest flux",
|
||||
"alert.no_bookmark": "Nu sunt înregistrări marcate.",
|
||||
"alert.no_category": "Nu sunt categorii.",
|
||||
"alert.no_category_entry": "Nu sunt înregistrări în această categorie.",
|
||||
"alert.no_feed": "Nu aveți fluxuri.",
|
||||
"alert.no_feed_entry": "Nu sunt înregistrări pentru acest flux.",
|
||||
"alert.no_feed_in_category": "Nu sunt fluxuri pentru această categorie.",
|
||||
"alert.no_history": "Nu există istoric în acest moment.",
|
||||
"alert.no_search_result": "Nu există înregistrări pentru această căutare.",
|
||||
"alert.no_shared_entry": "Nu sunt înregistrări partajate.",
|
||||
"alert.no_tag_entry": "Nu sunt înregistrări pentru această etichetă.",
|
||||
"alert.no_unread_entry": "Nu sunt intrări necitite.",
|
||||
"alert.no_user": "Sunteți singurul utilizator.",
|
||||
"alert.pocket_linked": "Contul dvs. Pocket este atașat!",
|
||||
"alert.prefs_saved": "Preferințe salvate!",
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"Ați activat actualizarea a prea multe fluxuri de informații. Vă rog să așteptați %d minut înainte de a reîncerca.",
|
||||
"Ați activat actualizarea a prea multe fluxuri de informații. Vă rog să așteptați %d minute înainte de a reîncerca.",
|
||||
"Ați activat actualizarea a prea multe fluxuri de informații. Vă rog să așteptați %d minute înainte de a reîncerca."
|
||||
],
|
||||
"confirm.loading": "În progres…",
|
||||
"confirm.no": "nu",
|
||||
"confirm.question": "Suneți sigur?",
|
||||
"confirm.question.refresh": "Sunteți sigur că vreți să forțați reîmprospătarea?",
|
||||
"confirm.yes": "da",
|
||||
"enclosure_media_controls.seek": "Caută:",
|
||||
"enclosure_media_controls.seek.title": "Caută %s secunde",
|
||||
"enclosure_media_controls.speed": "Viteză:",
|
||||
"enclosure_media_controls.speed.faster": "Mai rapid",
|
||||
"enclosure_media_controls.speed.faster.title": "Mai rapid cu %sx",
|
||||
"enclosure_media_controls.speed.reset": "Resetare",
|
||||
"enclosure_media_controls.speed.reset.title": "Resetare viteză la 1x",
|
||||
"enclosure_media_controls.speed.slower": "Mai încet",
|
||||
"enclosure_media_controls.speed.slower.title": "Mai încet cu %sx",
|
||||
"entry.bookmark.toast.off": "Fără stea",
|
||||
"entry.bookmark.toast.on": "Cu stea",
|
||||
"entry.bookmark.toggle.off": "Fără stea",
|
||||
"entry.bookmark.toggle.on": "Stea",
|
||||
"entry.comments.label": "Comentarii",
|
||||
"entry.comments.title": "Vizualizare Comentarii",
|
||||
"entry.estimated_reading_time": [
|
||||
"%d minut de lectură",
|
||||
"%d minute de lectură",
|
||||
"%d minut de lectură"
|
||||
],
|
||||
"entry.external_link.label": "Legătură externă",
|
||||
"entry.save.completed": "Gata!",
|
||||
"entry.save.label": "Salvare",
|
||||
"entry.save.title": "Salvez această înregistrare",
|
||||
"entry.save.toast.completed": "Înregistrare salvată",
|
||||
"entry.scraper.completed": "Gata!",
|
||||
"entry.scraper.label": "Descărcare",
|
||||
"entry.scraper.title": "Descarcă conținutul original",
|
||||
"entry.share.label": "Partajare",
|
||||
"entry.share.title": "Partajează această înregistrare",
|
||||
"entry.shared_entry.label": "Partajare",
|
||||
"entry.shared_entry.title": "Deschide legătura publică",
|
||||
"entry.state.loading": "Încarc…",
|
||||
"entry.state.saving": "Salvez…",
|
||||
"entry.status.mark_as_read": "Marcați ca citit",
|
||||
"entry.status.title": "Modifică starea intrării",
|
||||
"entry.status.toast.read": "Marcat ca citit",
|
||||
"entry.status.toast.unread": "Marcat ca necitit",
|
||||
"entry.status.mark_as_unread": "Marcați ca necitit",
|
||||
"entry.tags.label": "Etichete:",
|
||||
"entry.unshare.label": "Elimină partajarea",
|
||||
"error.api_key_already_exists": "Această cheie API există deja.",
|
||||
"error.bad_credentials": "Utilizator sau parolă invalide.",
|
||||
"error.category_already_exists": "Această categorie există deja.",
|
||||
"error.category_not_found": "Această categorie nu există sau nu aparține acestui utilizator.",
|
||||
"error.database_error": "Eroare bază de date: %v.",
|
||||
"error.different_passwords": "Parolele nu sunt identice.",
|
||||
"error.duplicate_fever_username": "Este deja cineva cu același cont de Fever!",
|
||||
"error.duplicate_googlereader_username": "Este deja cineva cu același nume de utilizator Google Reader!",
|
||||
"error.duplicate_linked_account": "Este deja cineva asociat cu acest furnizor!",
|
||||
"error.duplicated_feed": "Acest flux există deja.",
|
||||
"error.empty_file": "Acest fișier este gol.",
|
||||
"error.entries_per_page_invalid": "Numărul de înregistrări de pe pagină nu este valid.",
|
||||
"error.feed_already_exists": "Acest flux există deja.",
|
||||
"error.feed_category_not_found": "Această categorie nu există sau nu aparține utilizatorului.",
|
||||
"error.feed_format_not_detected": "Nu pot detecta formatul fluxului: %v.",
|
||||
"error.feed_invalid_blocklist_rule": "Blocul listei de reguli este invalid.",
|
||||
"error.feed_invalid_keeplist_rule": "Lista de reguli keep este invalidă.",
|
||||
"error.feed_mandatory_fields": "Adresa URL și categoria sunt obligatorii.",
|
||||
"error.feed_not_found": "Acest flux nu există sau un aparține acestui utilizator.",
|
||||
"error.feed_title_not_empty": "Titlul fluxului nu poate fi gol.",
|
||||
"error.feed_url_not_empty": "Adresa URL a fluxului nu poate fi goală.",
|
||||
"error.fields_mandatory": "Toate câmpurile sunt obligatorii.",
|
||||
"error.http_bad_gateway": "Acest site web nu este disponibil momentan din cauza unei erori generată de gateway. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
|
||||
"error.http_body_read": "Nu pot citi corpul HTTP: %v.",
|
||||
"error.http_client_error": "Eroare client HTTP: %v.",
|
||||
"error.http_empty_response": "Răspunsul HTTP este gol. Poate acest site web utilizează un mecanism împotriva boților?",
|
||||
"error.http_empty_response_body": "Corpul răspunsului HTTP este gol.",
|
||||
"error.http_forbidden": "Accesul la acest site web este interzis. Poate acesta utilizează un mecanism împotriva boților?",
|
||||
"error.http_gateway_timeout": "Acest site web nu este disponibil momentan din cauza unei erori generată de gateway. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
|
||||
"error.http_internal_server_error": "Acest site web nu este disponibil momentan din cauza unei erori generată de server. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
|
||||
"error.http_not_authorized": "Accesul la acest site nu este autorizat. Poate fi din cauza parolei sau a userului greșite.",
|
||||
"error.http_resource_not_found": "Resursa solicitată nu este găsită. Vă rog să verificați URL-ul.",
|
||||
"error.http_response_too_large": "Răspunsul HTTP este prea mare. Puteți crește dimensiunea acestuia în setările globale (necesită repornirea server-ului).",
|
||||
"error.http_service_unavailable": "Acest site web nu este disponibil momentan din cauza unei erori generată de server. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
|
||||
"error.http_too_many_requests": "Miniflux a generat prea multe solicitări pe acest site web. Vă rog, încercați mai tîrziu sau modificați configurațiile aplicației.",
|
||||
"error.http_unexpected_status_code": "Acest site web nu este disponibil momentan din cauza unei erori HTTP: %d. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
|
||||
"error.invalid_default_home_page": "Pagină de start invalidă!",
|
||||
"error.invalid_display_mode": "Mod invalid de afișare în aplicația web.",
|
||||
"error.invalid_entry_direction": "Direcție invalidă ăn intrare.",
|
||||
"error.invalid_feed_url": "Adresa URL a fluxului este invalidă.",
|
||||
"error.invalid_gesture_nav": "Gest de navigare invalid.",
|
||||
"error.invalid_language": "Limbă invalidă.",
|
||||
"error.invalid_site_url": "Adresa URL a site-ului este invalidă.",
|
||||
"error.invalid_theme": "Temă invalidă.",
|
||||
"error.invalid_timezone": "Dată/oră invalide.",
|
||||
"error.network_operation": "Miniflux nu poate ajunge la acest site din cauza unei erori de rețea: %v.",
|
||||
"error.network_timeout": "Acest site web este prea lent și conexiunea nu s-a realizat: %v",
|
||||
"error.password_min_length": "Parola trebuie să aibă cel puțin 6 caractere.",
|
||||
"error.pocket_access_token": "Nu poate obține token-ul de acces de la Pocket!",
|
||||
"error.pocket_request_token": "Nu poate obține token-ul solicitat de la Pocket!",
|
||||
"error.settings_block_rule_fieldname_invalid": "Regulă de bloc invalidă: regulii #%d îi lipsește un nume valid de câmp (Opțiuni: %s)",
|
||||
"error.settings_block_rule_invalid_regex": "Regulă de bloc invalidă: modelul regulii #%d's nu este regex valid",
|
||||
"error.settings_block_rule_regex_required": "Regulă de bloc invalidă: modelul regulii #%d's nu este furnizat",
|
||||
"error.settings_block_rule_separator_required": "Regulă de bloc invalidă: modelul regulii #%d's trebuie separat de '='",
|
||||
"error.settings_invalid_domain_list": "Lista domeniilor invalidă. Vă rugăm să ne furnizați o listă separată de virgulă a domeniilor.",
|
||||
"error.settings_keep_rule_fieldname_invalid": "Regulă Keep invalidă: regulii #%d îi lipsește un nume valid (Opțiuni: %s)",
|
||||
"error.settings_keep_rule_invalid_regex": "Regulă Keep invalidă: modelul regulii #%d's nu este regex valid",
|
||||
"error.settings_keep_rule_regex_required": "Regulă Keep invalidă: modelul regulii #%d nu este furnizat",
|
||||
"error.settings_keep_rule_separator_required": "Regulă Keep invalidă: modelul regulii #%d's trebuie separat de'='",
|
||||
"error.settings_mandatory_fields": "Numele utilizatorului, tema, limba și fusul orar sunt obligatorii.",
|
||||
"error.settings_media_playback_rate_range": "Viteza de rulare nu este validă",
|
||||
"error.settings_reading_speed_is_positive": "Vitezele de citire trebuie să fie numere întregi pozitive.",
|
||||
"error.site_url_not_empty": "Adresa URL a site-ului nu poate fi goală.",
|
||||
"error.subscription_not_found": "Nu se poate găsi nici un flux.",
|
||||
"error.title_required": "Titlul este obligatoriu.",
|
||||
"error.tls_error": "Eroare TLS: %q. Puteți dezactiva verificarea TLS în setările fluxurilor dacă doriți.",
|
||||
"error.unable_to_create_api_key": "Nu pot crea această cheie API.",
|
||||
"error.unable_to_create_category": "Nu se poate crea această categorie.",
|
||||
"error.unable_to_create_user": "Nu se poate crea utilizatorul.",
|
||||
"error.unable_to_detect_rssbridge": "Nu pot detecta fluxul când utilizez RSS-Bridge: %v.",
|
||||
"error.unable_to_parse_feed": "Nu pot procesa acest flux: %v.",
|
||||
"error.unable_to_update_category": "Nu se poate actualiza această categorie.",
|
||||
"error.unable_to_update_feed": "Nu se poate actualiza acest flux.",
|
||||
"error.unable_to_update_user": "Nu se poate actualiza utilizatorul.",
|
||||
"error.unlink_account_without_password": "Trebuie să definiți o parolă, altfel nu vă veți mai putea conecta.",
|
||||
"error.user_already_exists": "Acest utilizator există deja.",
|
||||
"error.user_mandatory_fields": "Numele utilizatorului este obligatoriu.",
|
||||
"form.api_key.label.description": "Etichetă Cheie API",
|
||||
"form.category.hide_globally": "Ascunde intrările în lista globală de articole necitite",
|
||||
"form.category.label.title": "Titlu",
|
||||
"form.feed.fieldset.general": "General",
|
||||
"form.feed.fieldset.integration": "Servicii Terțe",
|
||||
"form.feed.fieldset.network_settings": "Setări Rețea",
|
||||
"form.feed.fieldset.rules": "Reguli",
|
||||
"form.feed.label.allow_self_signed_certificates": "Permite certificatele auto-semnate sau invalide",
|
||||
"form.feed.label.apprise_service_urls": "Lista de URL-uri ale serviciilor Apprise separate prin virgule",
|
||||
"form.feed.label.blocklist_rules": "Reguli de Blocare",
|
||||
"form.feed.label.category": "Categorie",
|
||||
"form.feed.label.cookie": "Setare Cookie-uri",
|
||||
"form.feed.label.crawler": "Aduce conținutul original",
|
||||
"form.feed.label.description": "Descriere",
|
||||
"form.feed.label.disable_http2": "Dezactivează HTTP/2 pentru a preveni amprentarea",
|
||||
"form.feed.label.disabled": "Nu actualiza acest flux",
|
||||
"form.feed.label.feed_password": "Parolă Flux",
|
||||
"form.feed.label.feed_url": "Flux URL",
|
||||
"form.feed.label.feed_username": "Nume user Flux",
|
||||
"form.feed.label.fetch_via_proxy": "Actualizare prin proxy",
|
||||
"form.feed.label.hide_globally": "Ascunde intrările în lista globală de articole necitite",
|
||||
"form.feed.label.ignore_http_cache": "Ignoră cache HTTP",
|
||||
"form.feed.label.keeplist_rules": "Reguli de Păstrare",
|
||||
"form.feed.label.no_media_player": "Nu există player media (audio/video)",
|
||||
"form.feed.label.ntfy_activate": "Împinge intrările la ntfy",
|
||||
"form.feed.label.ntfy_default_priority": "Prioritate predefinită Ntfy",
|
||||
"form.feed.label.ntfy_high_priority": "Prioritate ridicată Ntfy",
|
||||
"form.feed.label.ntfy_low_priority": "Prioritate redusă Ntfy",
|
||||
"form.feed.label.ntfy_max_priority": "Prioritate maximă Ntfy",
|
||||
"form.feed.label.ntfy_min_priority": "Prioritate minimă Ntfy",
|
||||
"form.feed.label.ntfy_priority": "Prioritate Ntfy",
|
||||
"form.feed.label.ntfy_topic": "Subiect Ntfy (opțional)",
|
||||
"form.feed.label.pushover_activate": "Activează Pushover",
|
||||
"form.feed.label.pushover_default_priority": "Prioritate implicită Pushover",
|
||||
"form.feed.label.pushover_high_priority": "Prioritate ridicată Pushover",
|
||||
"form.feed.label.pushover_low_priority": "Prioritate redusă Pushover",
|
||||
"form.feed.label.pushover_max_priority": "Prioritate maximă Pushover",
|
||||
"form.feed.label.pushover_min_priority": "Prioritate minimă Pushover",
|
||||
"form.feed.label.pushover_priority": "Prioritate Pushover",
|
||||
"form.feed.label.rewrite_rules": "Rescrie Regulile",
|
||||
"form.feed.label.scraper_rules": "Reguli de Eliminare",
|
||||
"form.feed.label.site_url": "Adresă URL",
|
||||
"form.feed.label.title": "Titlu",
|
||||
"form.feed.label.urlrewrite_rules": "URL Reguli de Rescriere",
|
||||
"form.feed.label.user_agent": "Suprascrie User Agent Predefinit",
|
||||
"form.feed.label.webhook_url": "URL Webhook (pentru a primi notificări despre evenimentele de intrare)",
|
||||
"form.import.label.file": "Fișier OPML",
|
||||
"form.import.label.url": "URL",
|
||||
"form.integration.apprise_activate": "Trimite înregistrările pe Apprise",
|
||||
"form.integration.apprise_services_url": "URL-uri separate de virgulă cu servicii Apprise",
|
||||
"form.integration.apprise_url": "URL API Apprise",
|
||||
"form.integration.betula_activate": "Salvează înregistrările în Betula",
|
||||
"form.integration.betula_token": "Token Betula",
|
||||
"form.integration.betula_url": "Adresă server Betula",
|
||||
"form.integration.cubox_activate": "Salvează intrările în Cubox",
|
||||
"form.integration.cubox_api_link": "Link APi Cubox",
|
||||
"form.integration.discord_activate": "Împinge intrările pe Discord",
|
||||
"form.integration.discord_webhook_link": "Link Webhook Discord",
|
||||
"form.integration.espial_activate": "Save entries to Espial",
|
||||
"form.integration.espial_api_key": "Cheie API Espial",
|
||||
"form.integration.espial_endpoint": "Punct acces API Espial",
|
||||
"form.integration.espial_tags": "Etichete Espial",
|
||||
"form.integration.fever_activate": "Activează API Fever",
|
||||
"form.integration.fever_endpoint": "Punct access API Fever:",
|
||||
"form.integration.fever_password": "Parolă Fever",
|
||||
"form.integration.fever_username": "Utilizator Fever",
|
||||
"form.integration.googlereader_activate": "Activează API Google Reader",
|
||||
"form.integration.googlereader_endpoint": "Punct acces API Google Reader:",
|
||||
"form.integration.googlereader_password": "Parolă Google Reader",
|
||||
"form.integration.googlereader_username": "Utilizator Google Reader",
|
||||
"form.integration.instapaper_activate": "Salvează înregistrările pe Instapaper",
|
||||
"form.integration.instapaper_password": "Parolă Instapaper",
|
||||
"form.integration.instapaper_username": "Utilizator Instapaper",
|
||||
"form.integration.linkace_activate": "Salvează intrările în LinkAce",
|
||||
"form.integration.linkace_api_key": "Cheie API LinkAce",
|
||||
"form.integration.linkace_check_disabled": "Dezactivează verificarea link-urilor",
|
||||
"form.integration.linkace_endpoint": "Endpoint API LinkAce",
|
||||
"form.integration.linkace_is_private": "Marchează link-urile ca private",
|
||||
"form.integration.linkace_tags": "Tag-uri LinkAce",
|
||||
"form.integration.linkding_activate": "Salvează intrările în Linkding",
|
||||
"form.integration.linkding_api_key": "Cheie API Linkding",
|
||||
"form.integration.linkding_bookmark": "Marchează semnele de carte ca necitite",
|
||||
"form.integration.linkding_endpoint": "Endpoint API Linkding",
|
||||
"form.integration.linkding_tags": "TAG-uri Linkding",
|
||||
"form.integration.linkwarden_activate": "Salvează intrările în Linkwarden",
|
||||
"form.integration.linkwarden_api_key": "Cheie API Linkwarden",
|
||||
"form.integration.linkwarden_endpoint": "Endpoint API Linkwarden",
|
||||
"form.integration.matrix_bot_activate": "Împinge intrările noi pe Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID-ul Camerei Matrix",
|
||||
"form.integration.matrix_bot_password": "Parola utilizatorului Matrix",
|
||||
"form.integration.matrix_bot_url": "Server URL Matrix",
|
||||
"form.integration.matrix_bot_user": "Utilizator Matrix",
|
||||
"form.integration.notion_activate": "Salvează înregistrările în Notion",
|
||||
"form.integration.notion_page_id": "ID Pagină Notion",
|
||||
"form.integration.notion_token": "Token Secret Notion",
|
||||
"form.integration.ntfy_activate": "Împinge intrările pe ntfy",
|
||||
"form.integration.ntfy_api_token": "Token API Ntfy (opțional)",
|
||||
"form.integration.ntfy_icon_url": "Icon URL Ntfy (opțional)",
|
||||
"form.integration.ntfy_internal_links": "Utilizează legături interne la clic (opțional)",
|
||||
"form.integration.ntfy_password": "Parolă Ntfy (opțional)",
|
||||
"form.integration.ntfy_topic": "Topic Ntfy",
|
||||
"form.integration.ntfy_url": "URL Ntfy (opțional, predefinit este ntfy.sh)",
|
||||
"form.integration.ntfy_username": "Utilizator Ntfy (opțional)",
|
||||
"form.integration.nunux_keeper_activate": "Salvează înregistrările în Nunux Keeper",
|
||||
"form.integration.nunux_keeper_api_key": "Cheie API Nunux Keeper",
|
||||
"form.integration.nunux_keeper_endpoint": "Punct de acces API Keeper",
|
||||
"form.integration.omnivore_activate": "Salvare înregistrări în Omnivore",
|
||||
"form.integration.omnivore_api_key": "Cheie API Omnivore",
|
||||
"form.integration.omnivore_url": "Punct acces API Omnivore",
|
||||
"form.integration.pinboard_activate": "Salvează intrările în Pinboard",
|
||||
"form.integration.pinboard_bookmark": "Marchează bookmark ca necitit",
|
||||
"form.integration.pinboard_tags": "Etichete Pinboard",
|
||||
"form.integration.pinboard_token": "Token API Pinboard",
|
||||
"form.integration.pocket_access_token": "Token Acces Pocket",
|
||||
"form.integration.pocket_activate": "Salvează înregistrările în Pocket",
|
||||
"form.integration.pocket_connect_link": "Conectează contul Pocket personal",
|
||||
"form.integration.pocket_consumer_key": "Cheie Consumator Pocket",
|
||||
"form.integration.pushover_activate": "Activează Pushover",
|
||||
"form.integration.pushover_device": "Dispozitiv Pushover (opțional)",
|
||||
"form.integration.pushover_prefix": "Prefix Pushover (opțional)",
|
||||
"form.integration.pushover_token": "Token Pushover",
|
||||
"form.integration.pushover_user": "Utilizator Pushover",
|
||||
"form.integration.raindrop_activate": "Salvează intrările în Raindrop",
|
||||
"form.integration.raindrop_collection_id": "ID Colecție",
|
||||
"form.integration.raindrop_tags": "Tag-uri (separate de virgulă)",
|
||||
"form.integration.raindrop_token": "(Test) Token",
|
||||
"form.integration.readeck_activate": "Salvează intrările în readeck",
|
||||
"form.integration.readeck_api_key": "Cheie API Readeck",
|
||||
"form.integration.readeck_endpoint": "URL Readeck",
|
||||
"form.integration.readeck_labels": "Etichete Readeck",
|
||||
"form.integration.readeck_only_url": "Trimite numai URL (în loc de tot conținutul)",
|
||||
"form.integration.readwise_activate": "Salvare înregistrări în Readwise Reader",
|
||||
"form.integration.readwise_api_key": "Token Acces Readwise Reader",
|
||||
"form.integration.readwise_api_key_link": "Obțineți Token-ul de Acess pe Readwise",
|
||||
"form.integration.rssbridge_activate": "Verifică RSS-Bridge la adăugarea de abonamente",
|
||||
"form.integration.rssbridge_url": "URL server RSS-Bridge",
|
||||
"form.integration.shaarli_activate": "Salvează articolele în Shaarli",
|
||||
"form.integration.shaarli_api_secret": "Secret API Shaarli",
|
||||
"form.integration.shaarli_endpoint": "URL Shaarli",
|
||||
"form.integration.shiori_activate": "Salvează articolele în Shiori",
|
||||
"form.integration.shiori_endpoint": "Endpoint API Shiori",
|
||||
"form.integration.shiori_password": "Parolă Shiori",
|
||||
"form.integration.shiori_username": "Utilizator Shiori",
|
||||
"form.integration.slack_activate": "Împinge intrările pe Slack",
|
||||
"form.integration.slack_webhook_link": "Link Webhook Slack",
|
||||
"form.integration.telegram_bot_activate": "Împingeți înregistrările noi pe chat-ul Telegram",
|
||||
"form.integration.telegram_bot_disable_buttons": "Dezactivează butoanele",
|
||||
"form.integration.telegram_bot_disable_notification": "Dezactivează notificările",
|
||||
"form.integration.telegram_bot_disable_web_page_preview": "Dezactivează previzualizarea paginii web",
|
||||
"form.integration.telegram_bot_token": "Token Bot",
|
||||
"form.integration.telegram_chat_id": "ID Chat",
|
||||
"form.integration.telegram_topic_id": "ID Topic",
|
||||
"form.integration.wallabag_activate": "Salvează înregistrările în Wallabag",
|
||||
"form.integration.wallabag_client_id": "ID Client Wallabag",
|
||||
"form.integration.wallabag_client_secret": "Secret Client Wallabag",
|
||||
"form.integration.wallabag_endpoint": "URL Wallabag",
|
||||
"form.integration.wallabag_only_url": "Trimite numai URL-ul (fără conținut complet)",
|
||||
"form.integration.wallabag_password": "Parolă Wallabag",
|
||||
"form.integration.wallabag_username": "Utilizator Wallabag",
|
||||
"form.integration.webhook_activate": "Activează Webhook",
|
||||
"form.integration.webhook_secret": "Secret Webhook",
|
||||
"form.integration.webhook_url": "URL Webhook",
|
||||
"form.prefs.fieldset.application_settings": "Setări Aplicație",
|
||||
"form.prefs.fieldset.authentication_settings": "Setări Autentificare",
|
||||
"form.prefs.fieldset.global_feed_settings": "Setări Globale pt. Flux",
|
||||
"form.prefs.fieldset.reader_settings": "Setări Citire",
|
||||
"form.prefs.help.external_font_hosts": "Lista fonturilor de pe gazdă separate de virgulă care poate fi utilizate. De exemplu: \"fonts.gstatic.com fonts.googleapis.com\".",
|
||||
"form.prefs.label.categories_sorting_order": "Sortare categorii",
|
||||
"form.prefs.label.cjk_reading_speed": "Viteză de citire pentru Chineză, Coreană și Japoneză (caractere pe minut)",
|
||||
"form.prefs.label.custom_css": "CSS personalizat",
|
||||
"form.prefs.label.custom_js": "JavaScript personalizat",
|
||||
"form.prefs.label.default_home_page": "Pagina pornire predefinită",
|
||||
"form.prefs.label.default_reading_speed": "Viteză de citire pentru alte limbi (cuvinte pe minut)",
|
||||
"form.prefs.label.display_mode": "Mod afișare Aplicație Web Progresivă (PWA)",
|
||||
"form.prefs.label.entries_per_page": "Intrări pe pagină",
|
||||
"form.prefs.label.entry_order": "Coloană de sortare",
|
||||
"form.prefs.label.entry_sorting": "Sortare intrări",
|
||||
"form.prefs.label.entry_swipe": "Activare glisare pentru ecranele tactile",
|
||||
"form.prefs.label.external_font_hosts": "Fonturi externe gazdă",
|
||||
"form.prefs.label.gesture_nav": "Gesturi pentru navigare între înregistrări",
|
||||
"form.prefs.label.keyboard_shortcuts": "Activare scurtături tastatură",
|
||||
"form.prefs.label.language": "Limbă",
|
||||
"form.prefs.label.mark_read_manually": "Marchează manual intrările ca citite",
|
||||
"form.prefs.label.mark_read_on_media_completion": "Marchează ca citit numai când redarea de conținut audio/video atinge 90%%",
|
||||
"form.prefs.label.mark_read_on_view": "Marchează intrările ca citite la vizualizare",
|
||||
"form.prefs.label.mark_read_on_view_or_media_completion": "Marchează intrările ca citite la vizualizare. Pentru audio/video, marchează ca citit la redarea a 90%% de conținut",
|
||||
"form.prefs.label.media_playback_rate": "Viteza de rulare audio/video",
|
||||
"form.prefs.label.show_reading_time": "Afișare timp estimat de citire pentru înregistrări",
|
||||
"form.prefs.label.theme": "Temă",
|
||||
"form.prefs.label.timezone": "Fus orar",
|
||||
"form.prefs.select.alphabetical": "Alfabetic",
|
||||
"form.prefs.select.browser": "Browser",
|
||||
"form.prefs.select.created_time": "Dată creare înregistrare",
|
||||
"form.prefs.select.fullscreen": "Ecran complet",
|
||||
"form.prefs.select.minimal_ui": "Minim",
|
||||
"form.prefs.select.none": "Nimic",
|
||||
"form.prefs.select.older_first": "Intrările mai vechi la început",
|
||||
"form.prefs.select.publish_time": "Data publicare înregistrare",
|
||||
"form.prefs.select.recent_first": "Intrările mai noi la început",
|
||||
"form.prefs.select.standalone": "Independent",
|
||||
"form.prefs.select.swipe": "Glisare",
|
||||
"form.prefs.select.tap": "Apăsare dublă",
|
||||
"form.prefs.select.unread_count": "Contor necitite",
|
||||
"form.submit.loading": "Încarc…",
|
||||
"form.submit.saving": "Salvez…",
|
||||
"form.user.label.admin": "Administrator",
|
||||
"form.user.label.confirmation": "Confirmare Parolă",
|
||||
"form.user.label.password": "Parolă",
|
||||
"form.user.label.username": "Nume utilizator",
|
||||
"menu.about": "Despre",
|
||||
"menu.add_feed": "Adaugă flux",
|
||||
"menu.add_user": "Adaugă utilizator",
|
||||
"menu.api_keys": "Chei API",
|
||||
"menu.categories": "Categorii",
|
||||
"menu.create_api_key": "Crează o nouă cheie API",
|
||||
"menu.create_category": "Crează o categorie",
|
||||
"menu.edit_category": "Editare",
|
||||
"menu.edit_feed": "Editare",
|
||||
"menu.export": "Exportă",
|
||||
"menu.feed_entries": "Intrări",
|
||||
"menu.feeds": "Fluxuri",
|
||||
"menu.flush_history": "Elimină istoricul",
|
||||
"menu.history": "Istoric",
|
||||
"menu.home_page": "Pagina principală",
|
||||
"menu.import": "Importă",
|
||||
"menu.integrations": "Integrări",
|
||||
"menu.logout": "Deconectare",
|
||||
"menu.mark_all_as_read": "Marchează tot ca citit",
|
||||
"menu.mark_page_as_read": "Marchează această pagină ca citită",
|
||||
"menu.preferences": "Preferințe",
|
||||
"menu.refresh_all_feeds": "Reînnoiește toate fluxurile în fundal",
|
||||
"menu.refresh_feed": "Reînnoire",
|
||||
"menu.search": "Caută",
|
||||
"menu.sessions": "Sesiuni",
|
||||
"menu.settings": "Setări",
|
||||
"menu.shared_entries": "Intrări partajate",
|
||||
"menu.show_all_entries": "Afișează toate intrările",
|
||||
"menu.show_only_starred_entries": "Afișează numai intrările marcate",
|
||||
"menu.show_only_unread_entries": "Afișează numai intrările necitite",
|
||||
"menu.starred": "Marcat",
|
||||
"menu.title": "Meniu",
|
||||
"menu.unread": "Necitit",
|
||||
"menu.users": "Utilizatori",
|
||||
"page.about.author": "Autor:",
|
||||
"page.about.build_date": "Dată Build:",
|
||||
"page.about.credits": "Credit",
|
||||
"page.about.db_usage": "Utilizare Bază de Date",
|
||||
"page.about.global_config_options": "Opțiuni globale de configurare",
|
||||
"page.about.go_version": "Versiune Go:",
|
||||
"page.about.license": "Licență:",
|
||||
"page.about.postgres_version": "Versiune Postgres:",
|
||||
"page.about.title": "Despre",
|
||||
"page.about.version": "Versiune:",
|
||||
"page.add_feed.choose_feed": "Alegeți un flux",
|
||||
"page.add_feed.label.url": "URL",
|
||||
"page.add_feed.legend.advanced_options": "Opțiuni Avansate",
|
||||
"page.add_feed.no_category": "Nu există categorii. Trebuie să aveți măcar o categorie.",
|
||||
"page.add_feed.submit": "Găsește un flux",
|
||||
"page.add_feed.title": "Flux nou",
|
||||
"page.api_keys.never_used": "Niciodată Utilizată",
|
||||
"page.api_keys.table.actions": "Acțiuni",
|
||||
"page.api_keys.table.created_at": "Dată Creare",
|
||||
"page.api_keys.table.description": "Descriere",
|
||||
"page.api_keys.table.last_used_at": "Utilizat ultima dată",
|
||||
"page.api_keys.table.token": "Token",
|
||||
"page.api_keys.title": "Chei API",
|
||||
"page.categories_count": [
|
||||
"%d categorie",
|
||||
"%d categorii",
|
||||
"%d categorie găsită"
|
||||
],
|
||||
"page.categories.entries": "Intrări",
|
||||
"page.categories.feed_count": [
|
||||
"Este %d flux.",
|
||||
"Sunt %d fluxuri.",
|
||||
"Sunt %d fluxuri găsite."
|
||||
],
|
||||
"page.categories.feeds": "Fluxuri",
|
||||
"page.categories.no_feed": "Nici un flux.",
|
||||
"page.categories.title": "Categorii",
|
||||
"page.category_label": "Categorie: %s",
|
||||
"page.edit_category.title": "Editare Categorie: %s",
|
||||
"page.edit_feed.etag_header": "Antet ETag:",
|
||||
"page.edit_feed.last_check": "Ultima verificare:",
|
||||
"page.edit_feed.last_modified_header": "UltimaModificare antet:",
|
||||
"page.edit_feed.last_parsing_error": "Ultima Eroare la Analiză",
|
||||
"page.edit_feed.no_header": "Nimic",
|
||||
"page.edit_feed.title": "Editare Flux: %s",
|
||||
"page.edit_user.title": "Editare Utilizator: %s",
|
||||
"page.entry.attachments": "Atașamente",
|
||||
"page.feeds.error_count": [
|
||||
"%d eroare",
|
||||
"%d erori",
|
||||
"%d erori găsite"
|
||||
],
|
||||
"page.feeds.last_check": "Ultima verificare:",
|
||||
"page.feeds.next_check": "Următoarea verificare:",
|
||||
"page.feeds.read_counter": "Numărul de intrări citite",
|
||||
"page.feeds.title": "Fluxuri",
|
||||
"page.history.title": "Istoric",
|
||||
"page.import.title": "Import",
|
||||
"page.integration.bookmarklet": "Marcaje",
|
||||
"page.integration.bookmarklet.help": "Această legătură specială permite să vă abonați direct pe un site prin utilizarea unui marcaj în browser-ul web.",
|
||||
"page.integration.bookmarklet.instructions": "Trageți legătura în favorite.",
|
||||
"page.integration.bookmarklet.name": "Adaugă în Miniflux",
|
||||
"page.integration.miniflux_api": "API Miniflux",
|
||||
"page.integration.miniflux_api_endpoint": "Punct de acces API",
|
||||
"page.integration.miniflux_api_password": "Parolă",
|
||||
"page.integration.miniflux_api_password_value": "Parola contului",
|
||||
"page.integration.miniflux_api_username": "Utilizator",
|
||||
"page.integrations.title": "Integrări",
|
||||
"page.keyboard_shortcuts.close_modal": "Închide fereastra de dialog",
|
||||
"page.keyboard_shortcuts.download_content": "Descarcă conținutul original",
|
||||
"page.keyboard_shortcuts.go_to_bottom_item": "Du-te la ultimul obiect",
|
||||
"page.keyboard_shortcuts.go_to_categories": "Du-te la categorii",
|
||||
"page.keyboard_shortcuts.go_to_feed": "Du-te la flux",
|
||||
"page.keyboard_shortcuts.go_to_feeds": "Du-te la fluxuri",
|
||||
"page.keyboard_shortcuts.go_to_history": "Du-te la istoric",
|
||||
"page.keyboard_shortcuts.go_to_next_item": "Du-te la obiectul următor",
|
||||
"page.keyboard_shortcuts.go_to_next_page": "Du-te la pagina următoare",
|
||||
"page.keyboard_shortcuts.go_to_previous_item": "Du-te la obiectul anterior",
|
||||
"page.keyboard_shortcuts.go_to_previous_page": "Du-te la pagina anterioară",
|
||||
"page.keyboard_shortcuts.go_to_search": "Focusul pe formularul de căutare",
|
||||
"page.keyboard_shortcuts.go_to_settings": "Du-te la setări",
|
||||
"page.keyboard_shortcuts.go_to_starred": "Du-te la marcat",
|
||||
"page.keyboard_shortcuts.go_to_top_item": "Du-te la primul obiect",
|
||||
"page.keyboard_shortcuts.go_to_unread": "Du-te la necitit",
|
||||
"page.keyboard_shortcuts.mark_page_as_read": "Marchează pagina curentă ca citită",
|
||||
"page.keyboard_shortcuts.open_comments": "Deschide comentariile link-ului",
|
||||
"page.keyboard_shortcuts.open_comments_same_window": "Deschide comentariile link-ului în tab-ul curent",
|
||||
"page.keyboard_shortcuts.open_item": "Deschide obiectul selectat",
|
||||
"page.keyboard_shortcuts.open_original": "Deschide link-ul original",
|
||||
"page.keyboard_shortcuts.open_original_same_window": "Deschide link-ul original în tab-ul curent",
|
||||
"page.keyboard_shortcuts.refresh_all_feeds": "Reîncarcă toate fluxurile în fundal",
|
||||
"page.keyboard_shortcuts.remove_feed": "Elimină acest flux",
|
||||
"page.keyboard_shortcuts.save_article": "Salvare înregistrare",
|
||||
"page.keyboard_shortcuts.scroll_item_to_top": "Derulează obiectul la început",
|
||||
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Afișează scurtăturile tastaturii",
|
||||
"page.keyboard_shortcuts.subtitle.actions": "Acțiuni",
|
||||
"page.keyboard_shortcuts.subtitle.items": "Navigare Obiecte",
|
||||
"page.keyboard_shortcuts.subtitle.pages": "Navigare Pagini",
|
||||
"page.keyboard_shortcuts.subtitle.sections": "Navigare Secțiuni",
|
||||
"page.keyboard_shortcuts.title": "Scurtături Tastatură",
|
||||
"page.keyboard_shortcuts.toggle_bookmark_status": "Comută marcate",
|
||||
"page.keyboard_shortcuts.toggle_entry_attachments": "Comută deschis/închis pe atașamentele înregistrării",
|
||||
"page.keyboard_shortcuts.toggle_read_status_next": "Comută citit/necitit focus următor",
|
||||
"page.keyboard_shortcuts.toggle_read_status_prev": "Comută citit/necitit, focus anterior",
|
||||
"page.login.google_signin": "Conectare cu Google",
|
||||
"page.login.oidc_signin": "Conectare cu %s",
|
||||
"page.login.title": "Conectare",
|
||||
"page.login.webauthn_login": "Conectare cu cheia de acces",
|
||||
"page.login.webauthn_login.error": "Eroare la conectarea cu cheia de acces",
|
||||
"page.login.webauthn_login.help": "Vă rog să introduceți numele utilizatorului dacă utilizați o cheie. Nu este necesară dacă utilizați o cheie de acces (credențiale descoperibile).",
|
||||
"page.new_api_key.title": "Cheie API Nouă",
|
||||
"page.new_category.title": "Categorie Nouă",
|
||||
"page.new_user.title": "Utilizator Nou",
|
||||
"page.offline.message": "Sunteți offline",
|
||||
"page.offline.refresh_page": "Încercați să reîmprospătați pagina",
|
||||
"page.offline.title": "Mod Offline",
|
||||
"page.read_entry_count": [
|
||||
"%d înregistrare citită",
|
||||
"%d înregistrări citite",
|
||||
"%d înregistrări citite"
|
||||
],
|
||||
"page.search.title": "Rezultate Căutare",
|
||||
"page.sessions.table.actions": "Acțiuni",
|
||||
"page.sessions.table.current_session": "Sesiunea Curentă",
|
||||
"page.sessions.table.date": "Dată",
|
||||
"page.sessions.table.ip": "Adresă IP",
|
||||
"page.sessions.table.user_agent": "Agent Utilizator",
|
||||
"page.sessions.title": "Sesiuni",
|
||||
"page.settings.link_google_account": "Atașează contul personal Google",
|
||||
"page.settings.link_oidc_account": "Atașează contul meu %s",
|
||||
"page.settings.title": "Setări",
|
||||
"page.settings.unlink_google_account": "Decuplează contul personal Google",
|
||||
"page.settings.unlink_oidc_account": "Decuplează contul meu %s",
|
||||
"page.settings.webauthn.actions": "Acțiuni",
|
||||
"page.settings.webauthn.added_on": "Adăugată în",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Elimină %d cheie de acces",
|
||||
"Elimină %d chei de acces",
|
||||
"Elimină %d chei de acces"
|
||||
],
|
||||
"page.settings.webauthn.last_seen_on": "Utilizat ultima dată",
|
||||
"page.settings.webauthn.passkey_name": "Nume cheie acces",
|
||||
"page.settings.webauthn.passkeys": "Chei Acces",
|
||||
"page.settings.webauthn.register": "Înregistrare cheie acces",
|
||||
"page.settings.webauthn.register.error": "Eroare la înregistrarea cheii de acces",
|
||||
"page.shared_entries_count": [
|
||||
"%d înregistrare partajată",
|
||||
"%d înregistrări partajate",
|
||||
"%d înregistrări partajate"
|
||||
],
|
||||
"page.shared_entries.title": "Înregistrări partajate",
|
||||
"page.starred_entry_count": [
|
||||
"%d înregistrare marcată",
|
||||
"%d Înregistrări marcate",
|
||||
"%d Înregistrări marcate"
|
||||
],
|
||||
"page.starred.title": "Marcate",
|
||||
"page.total_entry_count": [
|
||||
"%d intrare în total",
|
||||
"%d intrări în total",
|
||||
"%d intrări în total"
|
||||
],
|
||||
"page.unread_entry_count": [
|
||||
"%d înregistrare necitită",
|
||||
"%d înregistrări necitite",
|
||||
"%d înregistrări necitite"
|
||||
],
|
||||
"page.unread.title": "Necitite",
|
||||
"page.users.actions": "Acțiuni",
|
||||
"page.users.admin.no": "Nu",
|
||||
"page.users.admin.yes": "Da",
|
||||
"page.users.is_admin": "Administrator",
|
||||
"page.users.last_login": "Ultima Conectare",
|
||||
"page.users.never_logged": "Niciodată",
|
||||
"page.users.title": "Utilizatori",
|
||||
"page.users.username": "Nume",
|
||||
"page.webauthn_rename.title": "Redenumire Cheie Acces",
|
||||
"pagination.first": "Prima",
|
||||
"pagination.last": "Ultima",
|
||||
"pagination.next": "Următor",
|
||||
"pagination.previous": "Anterior",
|
||||
"search.label": "Caută",
|
||||
"search.placeholder": "Caută…",
|
||||
"search.submit": "Caută",
|
||||
"skip_to_content": "Sari la conținut",
|
||||
"time_elapsed.days": [
|
||||
"%d zi în urmă",
|
||||
"%d zile în urmă",
|
||||
"%d zile în urmă"
|
||||
],
|
||||
"time_elapsed.hours": [
|
||||
"%d oră în urmă",
|
||||
"%d ore în urmă",
|
||||
"%d ore în urmă"
|
||||
],
|
||||
"time_elapsed.minutes": [
|
||||
"%d minut în urmă",
|
||||
"%d minute în urmă",
|
||||
"%d minute în urmă"
|
||||
],
|
||||
"time_elapsed.months": [
|
||||
"%d lună în urmă",
|
||||
"%d luni în urmă",
|
||||
"%d luni în urmă"
|
||||
],
|
||||
"time_elapsed.not_yet": "încă nu",
|
||||
"time_elapsed.now": "chiar acum",
|
||||
"time_elapsed.weeks": [
|
||||
"%d săptămână în urmă",
|
||||
"%d săptămâni în urmă",
|
||||
"%d săptămâni în urmă"
|
||||
],
|
||||
"time_elapsed.years": [
|
||||
"%d an în urmă",
|
||||
"%d ani în urmă",
|
||||
"%d ani în urmă"
|
||||
],
|
||||
"time_elapsed.yesterday": "ieri",
|
||||
"tooltip.keyboard_shortcuts": "Scurtături Tastatură: %s",
|
||||
"tooltip.logged_user": "Atentificat ca %s"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ type SessionData struct {
|
||||
WebAuthnSessionData WebAuthnSession `json:"webauthn_session_data"`
|
||||
}
|
||||
|
||||
func (s SessionData) String() string {
|
||||
func (s *SessionData) String() string {
|
||||
return fmt.Sprintf(`CSRF=%q, OAuth2State=%q, OAuth2CodeVerifier=%q, FlashMsg=%q, FlashErrMsg=%q, Lang=%q, Theme=%q, PocketTkn=%q, LastForceRefresh=%s, WebAuthnSession=%q`,
|
||||
s.CSRF,
|
||||
s.OAuth2State,
|
||||
@@ -40,7 +40,7 @@ func (s SessionData) String() string {
|
||||
}
|
||||
|
||||
// Value converts the session data to JSON.
|
||||
func (s SessionData) Value() (driver.Value, error) {
|
||||
func (s *SessionData) Value() (driver.Value, error) {
|
||||
j, err := json.Marshal(s)
|
||||
return j, err
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ type EnclosureUpdateRequest struct {
|
||||
}
|
||||
|
||||
// Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType
|
||||
func (e Enclosure) Html5MimeType() string {
|
||||
func (e *Enclosure) Html5MimeType() string {
|
||||
if e.MimeType == "video/m4v" {
|
||||
return "video/x-m4v"
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func (el EnclosureList) ContainsAudioOrVideo() bool {
|
||||
func (el EnclosureList) ProxifyEnclosureURL(router *mux.Router) {
|
||||
proxyOption := config.Opts.MediaProxyMode()
|
||||
|
||||
if proxyOption == "all" || proxyOption != "none" {
|
||||
if proxyOption != "none" {
|
||||
for i := range el {
|
||||
if urllib.IsHTTPS(el[i].URL) {
|
||||
for _, mediaType := range config.Opts.MediaProxyResourceTypes() {
|
||||
|
||||
@@ -53,8 +53,12 @@ type Feed struct {
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
AppriseServiceURLs string `json:"apprise_service_urls"`
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
NtfyEnabled bool `json:"ntfy_enabled"`
|
||||
NtfyPriority int `json:"ntfy_priority"`
|
||||
NtfyTopic string `json:"ntfy_topic"`
|
||||
PushoverEnabled bool `json:"pushover_enabled,omitempty"`
|
||||
PushoverPriority int `json:"pushover_priority,omitempty"`
|
||||
|
||||
// Non-persisted attributes
|
||||
Category *Category `json:"category,omitempty"`
|
||||
|
||||
@@ -10,10 +10,11 @@ import (
|
||||
|
||||
// Icon represents a website icon (favicon)
|
||||
type Icon struct {
|
||||
ID int64 `json:"id"`
|
||||
Hash string `json:"hash"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Content []byte `json:"-"`
|
||||
ID int64 `json:"id"`
|
||||
Hash string `json:"hash"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Content []byte `json:"-"`
|
||||
ExternalID string `json:"external_id"`
|
||||
}
|
||||
|
||||
// DataURL returns the data URL of the icon.
|
||||
@@ -26,6 +27,7 @@ type Icons []*Icon
|
||||
|
||||
// FeedIcon is a junction table between feeds and icons.
|
||||
type FeedIcon struct {
|
||||
FeedID int64 `json:"feed_id"`
|
||||
IconID int64 `json:"icon_id"`
|
||||
FeedID int64 `json:"feed_id"`
|
||||
IconID int64 `json:"icon_id"`
|
||||
ExternalIconID string `json:"external_icon_id"`
|
||||
}
|
||||
|
||||
@@ -109,4 +109,11 @@ type Integration struct {
|
||||
CuboxAPILink string
|
||||
DiscordEnabled bool
|
||||
DiscordWebhookLink string
|
||||
SlackEnabled bool
|
||||
SlackWebhookLink string
|
||||
PushoverEnabled bool
|
||||
PushoverUser string
|
||||
PushoverToken string
|
||||
PushoverDevice string
|
||||
PushoverPrefix string
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func (s WebAuthnSession) String() string {
|
||||
if s.SessionData == nil {
|
||||
return "{}"
|
||||
}
|
||||
return fmt.Sprintf("{Challenge: %s, UserID: %x}", s.SessionData.Challenge, s.SessionData.UserID)
|
||||
return fmt.Sprintf("{Challenge: %s, UserID: %x}", s.Challenge, s.UserID)
|
||||
}
|
||||
|
||||
type WebAuthnCredential struct {
|
||||
|
||||
@@ -39,5 +39,9 @@ func NewManager(ctx context.Context, clientID, clientSecret, redirectURL, oidcDi
|
||||
}
|
||||
}
|
||||
|
||||
if clientSecret == "" {
|
||||
slog.Warn("OIDC client secret is empty or missing.")
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -116,12 +116,12 @@ type Atom03Content struct {
|
||||
func (a *Atom03Content) Content() string {
|
||||
content := ""
|
||||
|
||||
switch {
|
||||
case a.Mode == "xml":
|
||||
switch a.Mode {
|
||||
case "xml":
|
||||
content = a.InnerXML
|
||||
case a.Mode == "escaped":
|
||||
case "escaped":
|
||||
content = a.CharData
|
||||
case a.Mode == "base64":
|
||||
case "base64":
|
||||
b, err := base64.StdEncoding.DecodeString(a.CharData)
|
||||
if err == nil {
|
||||
content = string(b)
|
||||
|
||||
@@ -354,9 +354,7 @@ func parseLocalTimeDates(layout, ds string) (t time.Time, err error) {
|
||||
// Workaround for dates that don't use GMT.
|
||||
if strings.HasSuffix(ds, "PST") || strings.HasSuffix(ds, "PDT") {
|
||||
loc, _ = time.LoadLocation("America/Los_Angeles")
|
||||
}
|
||||
|
||||
if strings.HasSuffix(ds, "EST") || strings.HasSuffix(ds, "EDT") {
|
||||
} else if strings.HasSuffix(ds, "EST") || strings.HasSuffix(ds, "EDT") {
|
||||
loc, _ = time.LoadLocation("America/New_York")
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ package encoding // import "miniflux.app/v2/internal/reader/encoding"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -23,7 +24,11 @@ import (
|
||||
// - Feeds with encoding specified only in XML document and not in HTTP header
|
||||
// - Feeds with wrong encoding defined and already in UTF-8
|
||||
func CharsetReader(charsetLabel string, input io.Reader) (io.Reader, error) {
|
||||
buffer, _ := io.ReadAll(input)
|
||||
buffer, err := io.ReadAll(input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`encoding: unable to read input: %w`, err)
|
||||
}
|
||||
|
||||
r := bytes.NewReader(buffer)
|
||||
|
||||
// The document is already UTF-8, do not do anything (avoid double-encoding).
|
||||
@@ -35,3 +40,24 @@ func CharsetReader(charsetLabel string, input io.Reader) (io.Reader, error) {
|
||||
// Transform document to UTF-8 from the specified encoding in XML prolog.
|
||||
return charset.NewReaderLabel(charsetLabel, r)
|
||||
}
|
||||
|
||||
// NewCharsetReader returns an io.Reader that converts the content of r to UTF-8.
|
||||
func NewCharsetReader(r io.Reader, contentType string) (io.Reader, error) {
|
||||
buffer, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`encoding: unable to read input: %w`, err)
|
||||
}
|
||||
|
||||
internalReader := bytes.NewReader(buffer)
|
||||
|
||||
// The document is already UTF-8, do not do anything.
|
||||
if utf8.Valid(buffer) {
|
||||
return internalReader, nil
|
||||
}
|
||||
|
||||
// Transform document to UTF-8 from the specified encoding in Content-Type header.
|
||||
// Note that only the first 1024 bytes are used to detect the encoding.
|
||||
// If the <meta charset> tag is not found in the first 1024 bytes, charset.DetermineEncoding returns "windows-1252" resulting in encoding issues.
|
||||
// See https://html.spec.whatwg.org/multipage/parsing.html#determining-the-character-encoding
|
||||
return charset.NewReader(internalReader, contentType)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package encoding // import "miniflux.app/v2/internal/reader/encoding"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestCharsetReaderWithUTF8(t *testing.T) {
|
||||
file := "testdata/utf8.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := CharsetReader("UTF-8", f)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetReaderWithISO88591(t *testing.T) {
|
||||
file := "testdata/iso-8859-1.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := CharsetReader("ISO-8859-1", f)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetReaderWithWindows1252(t *testing.T) {
|
||||
file := "testdata/windows-1252.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := CharsetReader("windows-1252", f)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Euro €"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetReaderWithInvalidProlog(t *testing.T) {
|
||||
file := "testdata/invalid-prolog.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := CharsetReader("invalid", f)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetReaderWithUTF8DocumentWithIncorrectProlog(t *testing.T) {
|
||||
file := "testdata/utf8-incorrect-prolog.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := CharsetReader("ISO-8859-1", f)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharsetReaderWithWindows1252DocumentWithIncorrectProlog(t *testing.T) {
|
||||
file := "testdata/windows-1252-incorrect-prolog.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := CharsetReader("windows-1252", f)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Euro €"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReaderWithUTF8Document(t *testing.T) {
|
||||
file := "testdata/utf8.html"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := NewCharsetReader(f, "text/html; charset=UTF-8")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReaderWithUTF8DocumentAndNoContentEncoding(t *testing.T) {
|
||||
file := "testdata/utf8.html"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := NewCharsetReader(f, "text/html")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReaderWithISO88591Document(t *testing.T) {
|
||||
file := "testdata/iso-8859-1.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := NewCharsetReader(f, "text/html; charset=ISO-8859-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReaderWithISO88591DocumentAndNoContentType(t *testing.T) {
|
||||
file := "testdata/iso-8859-1.xml"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := NewCharsetReader(f, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReaderWithISO88591DocumentWithMetaAfter1024Bytes(t *testing.T) {
|
||||
file := "testdata/iso-8859-1-meta-after-1024.html"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := NewCharsetReader(f, "text/html")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReaderWithUTF8DocumentWithMetaAfter1024Bytes(t *testing.T) {
|
||||
file := "testdata/utf8-meta-after-1024.html"
|
||||
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to open file: %v", err)
|
||||
}
|
||||
|
||||
reader, err := NewCharsetReader(f, "text/html")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create reader: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to read data: %v", err)
|
||||
}
|
||||
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatalf("Data is not valid UTF-8")
|
||||
}
|
||||
|
||||
expectedUnicodeString := "Café"
|
||||
if !bytes.Contains(data, []byte(expectedUnicodeString)) {
|
||||
t.Fatalf("Data does not contain expected unicode string: %s", expectedUnicodeString)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="invalid"?>
|
||||
<feed>
|
||||
<title>테스트 피드</title>
|
||||
<entry>
|
||||
<title>Café</title>
|
||||
</entry>
|
||||
</feed>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<!---
|
||||
|
||||
This text is greater than 1024 bytes which are used by the charset.NewReader to determine the encoding of the file.
|
||||
|
||||
This comment is used to pad the file to 1024 bytes.
|
||||
|
||||
The <meta> tag must be after 1024 bytes to ensure that the encoding is detected correctly.
|
||||
|
||||
---
|
||||
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
|
||||
-->
|
||||
<head>
|
||||
<meta charset="iso-8859-1">
|
||||
<title>Frédéric</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Café</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="iso-8859-1">
|
||||
<title>Frédéric</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Café</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="ISO-8859-1"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Café</title>
|
||||
<description>Présentation</description>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="ISO-8859-1"?>
|
||||
<feed>
|
||||
<title>테스트 피드</title>
|
||||
<entry>
|
||||
<title>Café</title>
|
||||
</entry>
|
||||
</feed>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<!---
|
||||
|
||||
This text is greater than 1024 bytes which are used by the charset.NewReader to determine the encoding of the file.
|
||||
|
||||
This comment is used to pad the file to 1024 bytes.
|
||||
|
||||
The <meta> tag must be after 1024 bytes to ensure that the encoding is detected correctly.
|
||||
|
||||
---
|
||||
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
More text to pad the file to 1024 bytes.
|
||||
|
||||
-->
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Frédéric</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Café</p>
|
||||
</body>
|
||||
</html>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Café</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Café</p>
|
||||
</body>
|
||||
</html>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed>
|
||||
<title>테스트 피드</title>
|
||||
<entry>
|
||||
<title>Café</title>
|
||||
</entry>
|
||||
</feed>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="windows-1252"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Euro €</title>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="windows-1252"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Euro €</title>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -21,12 +21,12 @@ import (
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/encoding"
|
||||
"miniflux.app/v2/internal/reader/fetcher"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"golang.org/x/image/draw"
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
type IconFinder struct {
|
||||
@@ -222,7 +222,7 @@ func resizeIcon(icon *model.Icon) *model.Icon {
|
||||
case "image/gif":
|
||||
src, err = gif.Decode(r)
|
||||
}
|
||||
if err != nil {
|
||||
if err != nil || src == nil {
|
||||
slog.Warn("unable to decode the icon", slog.Any("error", err))
|
||||
return icon
|
||||
}
|
||||
@@ -248,7 +248,7 @@ func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string,
|
||||
"link[rel='apple-touch-icon-precomposed.png']",
|
||||
}
|
||||
|
||||
htmlDocumentReader, err := charset.NewReader(body, contentType)
|
||||
htmlDocumentReader, err := encoding.NewCharsetReader(body, contentType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("icon: unable to create charset reader: %w", err)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,9 @@ func (h *Handler) Import(userID int64, data io.Reader) error {
|
||||
Category: category,
|
||||
}
|
||||
|
||||
h.store.CreateFeed(feed)
|
||||
if err := h.store.CreateFeed(feed); err != nil {
|
||||
return fmt.Errorf(`opml: unable to create this feed: %q`, subscription.FeedURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,11 +35,11 @@ type opmlOutline struct {
|
||||
Outlines opmlOutlineCollection `xml:"outline,omitempty"`
|
||||
}
|
||||
|
||||
func (outline opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
func (o opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
type opmlOutlineXml opmlOutline
|
||||
|
||||
outlineType := ""
|
||||
if outline.IsSubscription() {
|
||||
if o.IsSubscription() {
|
||||
outlineType = "rss"
|
||||
}
|
||||
|
||||
@@ -47,16 +47,16 @@ func (outline opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) er
|
||||
opmlOutlineXml
|
||||
Type string `xml:"type,attr,omitempty"`
|
||||
}{
|
||||
opmlOutlineXml: opmlOutlineXml(outline),
|
||||
opmlOutlineXml: opmlOutlineXml(o),
|
||||
Type: outlineType,
|
||||
}, start)
|
||||
}
|
||||
|
||||
func (o *opmlOutline) IsSubscription() bool {
|
||||
func (o opmlOutline) IsSubscription() bool {
|
||||
return strings.TrimSpace(o.FeedURL) != ""
|
||||
}
|
||||
|
||||
func (o *opmlOutline) GetTitle() string {
|
||||
func (o opmlOutline) GetTitle() string {
|
||||
if o.Title != "" {
|
||||
return o.Title
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func (o *opmlOutline) GetTitle() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (o *opmlOutline) GetSiteURL() string {
|
||||
func (o opmlOutline) GetSiteURL() string {
|
||||
if o.SiteURL != "" {
|
||||
return o.SiteURL
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor"
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
)
|
||||
|
||||
func isBlockedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool {
|
||||
if user.BlockFilterEntryRules != "" {
|
||||
rules := strings.Split(user.BlockFilterEntryRules, "\n")
|
||||
for _, rule := range rules {
|
||||
parts := strings.SplitN(rule, "=", 2)
|
||||
|
||||
var match bool
|
||||
switch parts[0] {
|
||||
case "EntryDate":
|
||||
datePattern := parts[1]
|
||||
match = isDateMatchingPattern(entry.Date, datePattern)
|
||||
case "EntryTitle":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Title)
|
||||
case "EntryURL":
|
||||
match, _ = regexp.MatchString(parts[1], entry.URL)
|
||||
case "EntryCommentsURL":
|
||||
match, _ = regexp.MatchString(parts[1], entry.CommentsURL)
|
||||
case "EntryContent":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Content)
|
||||
case "EntryAuthor":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Author)
|
||||
case "EntryTag":
|
||||
containsTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
match, _ = regexp.MatchString(parts[1], tag)
|
||||
return match
|
||||
})
|
||||
if containsTag {
|
||||
match = true
|
||||
}
|
||||
}
|
||||
|
||||
if match {
|
||||
slog.Debug("Blocking entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", rule),
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if feed.BlocklistRules == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
compiledBlocklist, err := regexp.Compile(feed.BlocklistRules)
|
||||
if err != nil {
|
||||
slog.Debug("Failed on regexp compilation",
|
||||
slog.String("pattern", feed.BlocklistRules),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
containsBlockedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
return compiledBlocklist.MatchString(tag)
|
||||
})
|
||||
|
||||
if compiledBlocklist.MatchString(entry.URL) || compiledBlocklist.MatchString(entry.Title) || compiledBlocklist.MatchString(entry.Author) || containsBlockedTag {
|
||||
slog.Debug("Blocking entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", feed.BlocklistRules),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isAllowedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool {
|
||||
if user.KeepFilterEntryRules != "" {
|
||||
rules := strings.Split(user.KeepFilterEntryRules, "\n")
|
||||
for _, rule := range rules {
|
||||
parts := strings.SplitN(rule, "=", 2)
|
||||
|
||||
var match bool
|
||||
switch parts[0] {
|
||||
case "EntryDate":
|
||||
datePattern := parts[1]
|
||||
match = isDateMatchingPattern(entry.Date, datePattern)
|
||||
case "EntryTitle":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Title)
|
||||
case "EntryURL":
|
||||
match, _ = regexp.MatchString(parts[1], entry.URL)
|
||||
case "EntryCommentsURL":
|
||||
match, _ = regexp.MatchString(parts[1], entry.CommentsURL)
|
||||
case "EntryContent":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Content)
|
||||
case "EntryAuthor":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Author)
|
||||
case "EntryTag":
|
||||
containsTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
match, _ = regexp.MatchString(parts[1], tag)
|
||||
return match
|
||||
})
|
||||
if containsTag {
|
||||
match = true
|
||||
}
|
||||
}
|
||||
|
||||
if match {
|
||||
slog.Debug("Allowing entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", rule),
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if feed.KeeplistRules == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
compiledKeeplist, err := regexp.Compile(feed.KeeplistRules)
|
||||
if err != nil {
|
||||
slog.Debug("Failed on regexp compilation",
|
||||
slog.String("pattern", feed.KeeplistRules),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
containsAllowedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
return compiledKeeplist.MatchString(tag)
|
||||
})
|
||||
|
||||
if compiledKeeplist.MatchString(entry.URL) || compiledKeeplist.MatchString(entry.Title) || compiledKeeplist.MatchString(entry.Author) || containsAllowedTag {
|
||||
slog.Debug("Allow entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", feed.KeeplistRules),
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isDateMatchingPattern(entryDate time.Time, pattern string) bool {
|
||||
if pattern == "future" {
|
||||
return entryDate.After(time.Now())
|
||||
}
|
||||
|
||||
parts := strings.SplitN(pattern, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
operator := parts[0]
|
||||
dateStr := parts[1]
|
||||
|
||||
switch operator {
|
||||
case "before":
|
||||
targetDate, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return entryDate.Before(targetDate)
|
||||
case "after":
|
||||
targetDate, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return entryDate.After(targetDate)
|
||||
case "between":
|
||||
dates := strings.Split(dateStr, ",")
|
||||
if len(dates) != 2 {
|
||||
return false
|
||||
}
|
||||
startDate, err1 := time.Parse("2006-01-02", dates[0])
|
||||
endDate, err2 := time.Parse("2006-01-02", dates[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
return entryDate.After(startDate) && entryDate.Before(endDate)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor"
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tdewolff/minify/v2"
|
||||
@@ -127,157 +125,17 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, userID int64,
|
||||
entry.Content = sanitizer.Sanitize(pageBaseURL, entry.Content)
|
||||
|
||||
updateEntryReadingTime(store, feed, entry, entryIsNew, user)
|
||||
|
||||
filteredEntries = append(filteredEntries, entry)
|
||||
}
|
||||
|
||||
if user.ShowReadingTime && shouldFetchYouTubeWatchTimeInBulk() {
|
||||
fetchYouTubeWatchTimeInBulk(filteredEntries)
|
||||
}
|
||||
|
||||
feed.Entries = filteredEntries
|
||||
}
|
||||
|
||||
func isBlockedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool {
|
||||
if user.BlockFilterEntryRules != "" {
|
||||
rules := strings.Split(user.BlockFilterEntryRules, "\n")
|
||||
for _, rule := range rules {
|
||||
parts := strings.SplitN(rule, "=", 2)
|
||||
|
||||
var match bool
|
||||
switch parts[0] {
|
||||
case "EntryDate":
|
||||
datePattern := parts[1]
|
||||
match = isDateMatchingPattern(entry.Date, datePattern)
|
||||
case "EntryTitle":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Title)
|
||||
case "EntryURL":
|
||||
match, _ = regexp.MatchString(parts[1], entry.URL)
|
||||
case "EntryCommentsURL":
|
||||
match, _ = regexp.MatchString(parts[1], entry.CommentsURL)
|
||||
case "EntryContent":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Content)
|
||||
case "EntryAuthor":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Author)
|
||||
case "EntryTag":
|
||||
containsTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
match, _ = regexp.MatchString(parts[1], tag)
|
||||
return match
|
||||
})
|
||||
if containsTag {
|
||||
match = true
|
||||
}
|
||||
}
|
||||
|
||||
if match {
|
||||
slog.Debug("Blocking entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", rule),
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if feed.BlocklistRules == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
compiledBlocklist, err := regexp.Compile(feed.BlocklistRules)
|
||||
if err != nil {
|
||||
slog.Debug("Failed on regexp compilation",
|
||||
slog.String("pattern", feed.BlocklistRules),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
containsBlockedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
return compiledBlocklist.MatchString(tag)
|
||||
})
|
||||
|
||||
if compiledBlocklist.MatchString(entry.URL) || compiledBlocklist.MatchString(entry.Title) || compiledBlocklist.MatchString(entry.Author) || containsBlockedTag {
|
||||
slog.Debug("Blocking entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", feed.BlocklistRules),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isAllowedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool {
|
||||
if user.KeepFilterEntryRules != "" {
|
||||
rules := strings.Split(user.KeepFilterEntryRules, "\n")
|
||||
for _, rule := range rules {
|
||||
parts := strings.SplitN(rule, "=", 2)
|
||||
|
||||
var match bool
|
||||
switch parts[0] {
|
||||
case "EntryDate":
|
||||
datePattern := parts[1]
|
||||
match = isDateMatchingPattern(entry.Date, datePattern)
|
||||
case "EntryTitle":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Title)
|
||||
case "EntryURL":
|
||||
match, _ = regexp.MatchString(parts[1], entry.URL)
|
||||
case "EntryCommentsURL":
|
||||
match, _ = regexp.MatchString(parts[1], entry.CommentsURL)
|
||||
case "EntryContent":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Content)
|
||||
case "EntryAuthor":
|
||||
match, _ = regexp.MatchString(parts[1], entry.Author)
|
||||
case "EntryTag":
|
||||
containsTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
match, _ = regexp.MatchString(parts[1], tag)
|
||||
return match
|
||||
})
|
||||
if containsTag {
|
||||
match = true
|
||||
}
|
||||
}
|
||||
|
||||
if match {
|
||||
slog.Debug("Allowing entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", rule),
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if feed.KeeplistRules == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
compiledKeeplist, err := regexp.Compile(feed.KeeplistRules)
|
||||
if err != nil {
|
||||
slog.Debug("Failed on regexp compilation",
|
||||
slog.String("pattern", feed.KeeplistRules),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
containsAllowedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
return compiledKeeplist.MatchString(tag)
|
||||
})
|
||||
|
||||
if compiledKeeplist.MatchString(entry.URL) || compiledKeeplist.MatchString(entry.Title) || compiledKeeplist.MatchString(entry.Author) || containsAllowedTag {
|
||||
slog.Debug("Allow entry based on rule",
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.String("rule", feed.KeeplistRules),
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ProcessEntryWebPage downloads the entry web page and apply rewrite rules.
|
||||
func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User) error {
|
||||
startTime := time.Now()
|
||||
@@ -358,94 +216,6 @@ func rewriteEntryURL(feed *model.Feed, entry *model.Entry) string {
|
||||
return rewrittenURL
|
||||
}
|
||||
|
||||
func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool, user *model.User) {
|
||||
if !user.ShowReadingTime {
|
||||
slog.Debug("Skip reading time estimation for this user", slog.Int64("user_id", user.ID))
|
||||
return
|
||||
}
|
||||
|
||||
if shouldFetchYouTubeWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchYouTubeWatchTime(entry.URL)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to fetch YouTube watch time",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
if shouldFetchNebulaWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchNebulaWatchTime(entry.URL)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to fetch Nebula watch time",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
if shouldFetchOdyseeWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchOdyseeWatchTime(entry.URL)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to fetch Odysee watch time",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
if shouldFetchBilibiliWatchTime(entry) {
|
||||
if entryIsNew {
|
||||
watchTime, err := fetchBilibiliWatchTime(entry.URL)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to fetch Bilibili watch time",
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
entry.ReadingTime = watchTime
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle YT error case and non-YT entries.
|
||||
if entry.ReadingTime == 0 {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
func isRecentEntry(entry *model.Entry) bool {
|
||||
if config.Opts.FilterEntryMaxAgeDays() == 0 || entry.Date.After(time.Now().AddDate(0, 0, -config.Opts.FilterEntryMaxAgeDays())) {
|
||||
return true
|
||||
@@ -468,44 +238,3 @@ func minifyEntryContent(entryContent string) string {
|
||||
|
||||
return entryContent
|
||||
}
|
||||
|
||||
func isDateMatchingPattern(entryDate time.Time, pattern string) bool {
|
||||
if pattern == "future" {
|
||||
return entryDate.After(time.Now())
|
||||
}
|
||||
|
||||
parts := strings.SplitN(pattern, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
operator := parts[0]
|
||||
dateStr := parts[1]
|
||||
|
||||
switch operator {
|
||||
case "before":
|
||||
targetDate, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return entryDate.Before(targetDate)
|
||||
case "after":
|
||||
targetDate, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return entryDate.After(targetDate)
|
||||
case "between":
|
||||
dates := strings.Split(dateStr, ",")
|
||||
if len(dates) != 2 {
|
||||
return false
|
||||
}
|
||||
startDate, err1 := time.Parse("2006-01-02", dates[0])
|
||||
endDate, err2 := time.Parse("2006-01-02", dates[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
return entryDate.After(startDate) && entryDate.Before(endDate)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor"
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/readingtime"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
)
|
||||
|
||||
func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool, user *model.User) {
|
||||
if !user.ShowReadingTime {
|
||||
slog.Debug("Skip reading time estimation for this user", slog.Int64("user_id", user.ID))
|
||||
return
|
||||
}
|
||||
|
||||
// Define a type for watch time fetching functions
|
||||
type watchTimeFetcher func(string) (int, error)
|
||||
|
||||
// Define watch time fetching scenarios
|
||||
watchTimeScenarios := []struct {
|
||||
shouldFetch func(*model.Entry) bool
|
||||
fetchFunc watchTimeFetcher
|
||||
platform string
|
||||
}{
|
||||
{shouldFetchYouTubeWatchTimeForSingleEntry, fetchYouTubeWatchTimeForSingleEntry, "YouTube"},
|
||||
{shouldFetchNebulaWatchTime, fetchNebulaWatchTime, "Nebula"},
|
||||
{shouldFetchOdyseeWatchTime, fetchOdyseeWatchTime, "Odysee"},
|
||||
{shouldFetchBilibiliWatchTime, fetchBilibiliWatchTime, "Bilibili"},
|
||||
}
|
||||
|
||||
// Iterate through scenarios and attempt to fetch watch time
|
||||
for _, scenario := range watchTimeScenarios {
|
||||
if scenario.shouldFetch(entry) {
|
||||
if entryIsNew {
|
||||
if watchTime, err := scenario.fetchFunc(entry.URL); err != nil {
|
||||
slog.Warn("Unable to fetch watch time",
|
||||
slog.String("platform", scenario.platform),
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Int64("feed_id", feed.ID),
|
||||
slog.String("feed_url", feed.FeedURL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else {
|
||||
entry.ReadingTime = watchTime
|
||||
}
|
||||
} else {
|
||||
entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to text-based reading time estimation
|
||||
if entry.ReadingTime == 0 && entry.Content != "" {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package processor
|
||||
package processor // import "miniflux.app/v2/internal/reader/processor"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
@@ -25,24 +26,30 @@ var (
|
||||
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)?)?$`)
|
||||
)
|
||||
|
||||
func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
|
||||
if !config.Opts.FetchYouTubeWatchTime() {
|
||||
return false
|
||||
}
|
||||
matches := youtubeRegex.FindStringSubmatch(entry.URL)
|
||||
urlMatchesYouTubePattern := len(matches) == 2
|
||||
return urlMatchesYouTubePattern
|
||||
func isYouTubeVideoURL(websiteURL string) bool {
|
||||
return len(youtubeRegex.FindStringSubmatch(websiteURL)) == 2
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTime(websiteURL string) (int, error) {
|
||||
if config.Opts.YouTubeApiKey() == "" {
|
||||
return fetchYouTubeWatchTimeFromWebsite(websiteURL)
|
||||
} else {
|
||||
return fetchYouTubeWatchTimeFromApi(websiteURL)
|
||||
func getVideoIDFromYouTubeURL(websiteURL string) string {
|
||||
parsedWebsiteURL, err := url.Parse(websiteURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parsedWebsiteURL.Query().Get("v")
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTimeFromWebsite(websiteURL string) (int, error) {
|
||||
func shouldFetchYouTubeWatchTimeForSingleEntry(entry *model.Entry) bool {
|
||||
return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeApiKey() == "" && isYouTubeVideoURL(entry.URL)
|
||||
}
|
||||
|
||||
func shouldFetchYouTubeWatchTimeInBulk() bool {
|
||||
return config.Opts.FetchYouTubeWatchTime() && config.Opts.YouTubeApiKey() != ""
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTimeForSingleEntry(websiteURL string) (int, error) {
|
||||
slog.Debug("Fetching YouTube watch time for a single entry", slog.String("website_url", websiteURL))
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
@@ -60,31 +67,59 @@ func fetchYouTubeWatchTimeFromWebsite(websiteURL string) (int, error) {
|
||||
return 0, docErr
|
||||
}
|
||||
|
||||
durs, exists := doc.FindMatcher(goquery.Single(`meta[itemprop="duration"]`)).Attr("content")
|
||||
htmlDuration, exists := doc.FindMatcher(goquery.Single(`meta[itemprop="duration"]`)).Attr("content")
|
||||
if !exists {
|
||||
return 0, errors.New("duration has not found")
|
||||
return 0, errors.New("youtube: duration has not found")
|
||||
}
|
||||
|
||||
dur, err := parseISO8601(durs)
|
||||
parsedDuration, err := parseISO8601(htmlDuration)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
|
||||
return 0, fmt.Errorf("youtube: unable to parse duration %s: %v", htmlDuration, err)
|
||||
}
|
||||
|
||||
return int(dur.Minutes()), nil
|
||||
return int(parsedDuration.Minutes()), nil
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTimeFromApi(websiteURL string) (int, error) {
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
func fetchYouTubeWatchTimeInBulk(entries []*model.Entry) {
|
||||
var videosEntriesMapping = make(map[string]*model.Entry)
|
||||
var videoIDs []string
|
||||
|
||||
parsedWebsiteURL, err := url.Parse(websiteURL)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse URL: %v", err)
|
||||
for _, entry := range entries {
|
||||
if !isYouTubeVideoURL(entry.URL) {
|
||||
continue
|
||||
}
|
||||
|
||||
youtubeVideoID := getVideoIDFromYouTubeURL(entry.URL)
|
||||
if youtubeVideoID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
videosEntriesMapping[getVideoIDFromYouTubeURL(entry.URL)] = entry
|
||||
videoIDs = append(videoIDs, youtubeVideoID)
|
||||
}
|
||||
|
||||
if len(videoIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
watchTimeMap, err := fetchYouTubeWatchTimeFromApiInBulk(videoIDs)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to fetch YouTube watch time in bulk", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
|
||||
for videoID, watchTime := range watchTimeMap {
|
||||
if entry, ok := videosEntriesMapping[videoID]; ok {
|
||||
entry.ReadingTime = int(watchTime.Minutes())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchYouTubeWatchTimeFromApiInBulk(videoIDs []string) (map[string]time.Duration, error) {
|
||||
slog.Debug("Fetching YouTube watch time in bulk", slog.Any("video_ids", videoIDs))
|
||||
|
||||
apiQuery := url.Values{}
|
||||
apiQuery.Set("id", parsedWebsiteURL.Query().Get("v"))
|
||||
apiQuery.Set("id", strings.Join(videoIDs, ","))
|
||||
apiQuery.Set("key", config.Opts.YouTubeApiKey())
|
||||
apiQuery.Set("part", "contentDetails")
|
||||
|
||||
@@ -95,37 +130,33 @@ func fetchYouTubeWatchTimeFromApi(websiteURL string) (int, error) {
|
||||
RawQuery: apiQuery.Encode(),
|
||||
}
|
||||
|
||||
requestBuilder := fetcher.NewRequestBuilder()
|
||||
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
|
||||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(apiURL.String()))
|
||||
defer responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
slog.Warn("Unable to fetch contentDetails from YouTube API", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
|
||||
return 0, localizedError.Error()
|
||||
}
|
||||
|
||||
var videos struct {
|
||||
Items []struct {
|
||||
ContentDetails struct {
|
||||
Duration string `json:"duration"`
|
||||
} `json:"contentDetails"`
|
||||
} `json:"items"`
|
||||
slog.Warn("Unable to fetch contentDetails from YouTube API", slog.Any("error", localizedError.Error()))
|
||||
return nil, localizedError.Error()
|
||||
}
|
||||
|
||||
var videos youtubeVideoListResponse
|
||||
if err := json.NewDecoder(responseHandler.Body(config.Opts.HTTPClientMaxBodySize())).Decode(&videos); err != nil {
|
||||
return 0, fmt.Errorf("unable to decode JSON: %v", err)
|
||||
return nil, fmt.Errorf("youtube: unable to decode JSON: %v", err)
|
||||
}
|
||||
|
||||
if n := len(videos.Items); n != 1 {
|
||||
return 0, fmt.Errorf("invalid items length: %d", n)
|
||||
watchTimeMap := make(map[string]time.Duration)
|
||||
for _, video := range videos.Items {
|
||||
duration, err := parseISO8601(video.ContentDetails.Duration)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to parse ISO8601 duration", slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
watchTimeMap[video.ID] = duration
|
||||
}
|
||||
|
||||
durs := videos.Items[0].ContentDetails.Duration
|
||||
dur, err := parseISO8601(durs)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
|
||||
}
|
||||
|
||||
return int(dur.Minutes()), nil
|
||||
return watchTimeMap, nil
|
||||
}
|
||||
|
||||
func parseISO8601(from string) (time.Duration, error) {
|
||||
@@ -135,7 +166,7 @@ func parseISO8601(from string) (time.Duration, error) {
|
||||
if iso8601Regex.MatchString(from) {
|
||||
match = iso8601Regex.FindStringSubmatch(from)
|
||||
} else {
|
||||
return 0, errors.New("could not parse duration string")
|
||||
return 0, errors.New("youtube: could not parse duration string")
|
||||
}
|
||||
|
||||
for i, name := range iso8601Regex.SubexpNames() {
|
||||
@@ -151,15 +182,24 @@ func parseISO8601(from string) (time.Duration, error) {
|
||||
|
||||
switch name {
|
||||
case "hour":
|
||||
d += (time.Duration(val) * time.Hour)
|
||||
d += time.Duration(val) * time.Hour
|
||||
case "minute":
|
||||
d += (time.Duration(val) * time.Minute)
|
||||
d += time.Duration(val) * time.Minute
|
||||
case "second":
|
||||
d += (time.Duration(val) * time.Second)
|
||||
d += time.Duration(val) * time.Second
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown field %s", name)
|
||||
return 0, fmt.Errorf("youtube: unknown field %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
type youtubeVideoListResponse struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
ContentDetails struct {
|
||||
Duration string `json:"duration"`
|
||||
} `json:"contentDetails"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
@@ -36,3 +36,37 @@ func TestParseISO8601(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetYouTubeVideoIDFromURL(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
url string
|
||||
expected string
|
||||
}{
|
||||
{"https://www.youtube.com/watch?v=HLrqNhgdiC0", "HLrqNhgdiC0"},
|
||||
{"https://www.youtube.com/watch?v=HLrqNhgdiC0&feature=youtu.be", "HLrqNhgdiC0"},
|
||||
{"https://example.org/test", ""},
|
||||
}
|
||||
for _, tc := range scenarios {
|
||||
result := getVideoIDFromYouTubeURL(tc.url)
|
||||
if tc.expected != result {
|
||||
t.Errorf(`Unexpected result, got %q for url %q`, result, tc.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsYouTubeVideoURL(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
url string
|
||||
expected bool
|
||||
}{
|
||||
{"https://www.youtube.com/watch?v=HLrqNhgdiC0", true},
|
||||
{"https://www.youtube.com/watch?v=HLrqNhgdiC0&feature=youtu.be", true},
|
||||
{"https://example.org/test", false},
|
||||
}
|
||||
for _, tc := range scenarios {
|
||||
result := isYouTubeVideoURL(tc.url)
|
||||
if tc.expected != result {
|
||||
t.Errorf(`Unexpected result, got %v for url %q`, result, tc.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,6 @@ func TestGetRefererForURL(t *testing.T) {
|
||||
url: "https://scontent-sjc3-1.cdninstagram.com/example.jpg",
|
||||
expected: "https://www.instagram.com",
|
||||
},
|
||||
{
|
||||
name: "Piokok URL",
|
||||
url: "https://sp1.piokok.com/example.jpg",
|
||||
expected: "https://sp1.piokok.com",
|
||||
},
|
||||
{
|
||||
name: "Weibo Video URL",
|
||||
url: "https://f.video.weibocdn.com/example.mp4",
|
||||
|
||||
@@ -36,7 +36,6 @@ var predefinedRules = map[string]string{
|
||||
"thedoghousediaries.com": "add_image_title",
|
||||
"theverge.com": `add_dynamic_image, remove("div.duet--recirculation--related-list, .hidden")`,
|
||||
"treelobsters.com": "add_image_title",
|
||||
"webtoons.com": `add_dynamic_image,replace("webtoon"|"swebtoon")`,
|
||||
"www.qwantz.com": "add_image_title,add_mailto_subject",
|
||||
"xkcd.com": "add_image_title",
|
||||
"youtube.com": "add_youtube_video",
|
||||
@@ -50,31 +49,29 @@ func GetRefererForURL(u string) string {
|
||||
}
|
||||
|
||||
switch parsedUrl.Hostname() {
|
||||
case "moyu.im":
|
||||
return "https://i.jandan.net"
|
||||
case "i.pximg.net":
|
||||
return "https://www.pixiv.net"
|
||||
case "sp1.piokok.com":
|
||||
return "https://sp1.piokok.com"
|
||||
case "appinn.com":
|
||||
return "https://appinn.com"
|
||||
case "bjp.org.cn":
|
||||
return "https://bjp.org.cn"
|
||||
case "cdnfile.sspai.com":
|
||||
return "https://sspai.com"
|
||||
case "f.video.weibocdn.com":
|
||||
return "https://weibo.com"
|
||||
case "i.pximg.net":
|
||||
return "https://www.pixiv.net"
|
||||
case "img.hellogithub.com":
|
||||
return "https://hellogithub.com"
|
||||
case "bjp.org.cn":
|
||||
return "https://bjp.org.cn"
|
||||
case "appinn.com":
|
||||
return "https://appinn.com"
|
||||
case "moyu.im":
|
||||
return "https://i.jandan.net"
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(parsedUrl.Hostname(), ".sinaimg.cn"):
|
||||
return "https://weibo.com"
|
||||
case strings.HasSuffix(parsedUrl.Hostname(), ".cdninstagram.com"):
|
||||
return "https://www.instagram.com"
|
||||
case strings.HasSuffix(parsedUrl.Hostname(), ".moyu.im"):
|
||||
return "https://i.jandan.net"
|
||||
case strings.HasSuffix(parsedUrl.Hostname(), ".sinaimg.cn"):
|
||||
return "https://weibo.com"
|
||||
}
|
||||
|
||||
return ""
|
||||
|
||||
@@ -39,7 +39,7 @@ func (r *RSSAdapter) BuildFeed(baseURL string) *model.Feed {
|
||||
}
|
||||
|
||||
// Try to find the feed URL from the Atom links.
|
||||
for _, atomLink := range r.rss.Channel.AtomLinks.Links {
|
||||
for _, atomLink := range r.rss.Channel.Links {
|
||||
atomLinkHref := strings.TrimSpace(atomLink.Href)
|
||||
if atomLinkHref != "" && atomLink.Rel == "self" {
|
||||
if absoluteFeedURL, err := urllib.AbsoluteURL(feed.FeedURL, atomLinkHref); err == nil {
|
||||
@@ -173,13 +173,13 @@ func findFeedAuthor(rssChannel *RSSChannel) string {
|
||||
}
|
||||
|
||||
func findEntryTitle(rssItem *RSSItem) string {
|
||||
title := rssItem.Title
|
||||
title := rssItem.Title.Content
|
||||
|
||||
if rssItem.DublinCoreTitle != "" {
|
||||
title = rssItem.DublinCoreTitle
|
||||
}
|
||||
|
||||
return html.UnescapeString(strings.TrimSpace(title))
|
||||
return html.UnescapeString(html.UnescapeString(strings.TrimSpace(title)))
|
||||
}
|
||||
|
||||
func findEntryURL(rssItem *RSSItem) string {
|
||||
@@ -189,7 +189,7 @@ func findEntryURL(rssItem *RSSItem) string {
|
||||
}
|
||||
}
|
||||
|
||||
for _, atomLink := range rssItem.AtomLinks.Links {
|
||||
for _, atomLink := range rssItem.Links {
|
||||
if atomLink.Href != "" && (strings.EqualFold(atomLink.Rel, "alternate") || atomLink.Rel == "") {
|
||||
return strings.TrimSpace(atomLink.Href)
|
||||
}
|
||||
@@ -253,8 +253,8 @@ func findEntryAuthor(rssItem *RSSItem) string {
|
||||
author = rssItem.ItunesAuthor
|
||||
case rssItem.DublinCoreCreator != "":
|
||||
author = rssItem.DublinCoreCreator
|
||||
case rssItem.AtomAuthor.PersonName() != "":
|
||||
author = rssItem.AtomAuthor.PersonName()
|
||||
case rssItem.PersonName() != "":
|
||||
author = rssItem.PersonName()
|
||||
case strings.Contains(rssItem.Author.Inner, "<![CDATA["):
|
||||
author = rssItem.Author.Data
|
||||
default:
|
||||
|
||||
+178
-133
@@ -311,6 +311,184 @@ func TestParseEntryWithDCTitleOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedTitleWithHTMLEntity(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title>Example Feed</title>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Title != "Example \u00a0 Feed" {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedTitleWithUnicodeEntityAndCdata(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title><![CDATA[Jenny’s Newsletter]]></title>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Title != `Jenny’s Newsletter` {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithHTMLEntity(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title>Example</title>
|
||||
<item>
|
||||
<title></example></title>
|
||||
<link>http://www.example.org/entries/1</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "</example>" {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithNumericCharacterReference(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title>Example</title>
|
||||
<item>
|
||||
<title>Σ ß</title>
|
||||
<link>http://www.example.org/article.html</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "Σ ß" {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithDoubleEncodedEntities(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title>Example</title>
|
||||
<item>
|
||||
<title>&#39;Text&#39;</title>
|
||||
<link>http://www.example.org/article.html</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "'Text'" {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithWhitespaces(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Example</title>
|
||||
<link>http://example.org</link>
|
||||
<item>
|
||||
<title>
|
||||
Some Title
|
||||
</title>
|
||||
<link>http://www.example.org/entries/1</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "Some Title" {
|
||||
t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithCDATA(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Example</title>
|
||||
<link>http://example.org</link>
|
||||
<item>
|
||||
<title><![CDATA[This is a title]]></title>
|
||||
<link>http://www.example.org/entries/1</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "This is a title" {
|
||||
t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithInnerHTML(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Example</title>
|
||||
<link>http://example.org</link>
|
||||
<item>
|
||||
<title>Test: <b>bold</b></title>
|
||||
<link>http://www.example.org/entries/1</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "Test: bold" {
|
||||
t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryWithoutLink(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
@@ -997,32 +1175,6 @@ func TestParseEntryWithFeedBurnerLink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryTitleWithWhitespaces(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Example</title>
|
||||
<link>http://example.org</link>
|
||||
<item>
|
||||
<title>
|
||||
Some Title
|
||||
</title>
|
||||
<link>http://www.example.org/entries/1</link>
|
||||
<pubDate>Fri, 15 Jul 2005 00:00:00 -0500</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "Some Title" {
|
||||
t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryWithEnclosures(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
@@ -1380,113 +1532,6 @@ func TestParseInvalidXml(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedTitleWithHTMLEntity(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title>Example Feed</title>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Title != "Example \u00a0 Feed" {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedTitleWithUnicodeEntityAndCdata(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title><![CDATA[Jenny’s Newsletter]]></title>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Title != `Jenny’s Newsletter` {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithHTMLEntity(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title>Example</title>
|
||||
<item>
|
||||
<title></example></title>
|
||||
<link>http://www.example.org/entries/1</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "</example>" {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithNumericCharacterReference(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title>Example</title>
|
||||
<item>
|
||||
<title>Σ ß</title>
|
||||
<link>http://www.example.org/article.html</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "Σ ß" {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseItemTitleWithDoubleEncodedEntities(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
<channel>
|
||||
<link>https://example.org/</link>
|
||||
<title>Example</title>
|
||||
<item>
|
||||
<title>&#39;Text&#39;</title>
|
||||
<link>http://www.example.org/article.html</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Entries[0].Title != "'Text'" {
|
||||
t.Errorf(`Incorrect title, got: %q`, feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedLinkWithInvalidCharacterEntity(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
|
||||
@@ -111,7 +111,7 @@ type RSSImage struct {
|
||||
|
||||
type RSSItem struct {
|
||||
// Title is the title of the item.
|
||||
Title string `xml:"rss title"`
|
||||
Title InnerContent `xml:"rss title"`
|
||||
|
||||
// Link is the URL of the item.
|
||||
Link string `xml:"rss link"`
|
||||
@@ -198,3 +198,28 @@ type RSSSource struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Name string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type InnerContent struct {
|
||||
Content string
|
||||
}
|
||||
|
||||
func (ic *InnerContent) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
var content strings.Builder
|
||||
|
||||
for {
|
||||
token, err := d.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch t := token.(type) {
|
||||
case xml.CharData:
|
||||
content.Write(t)
|
||||
case xml.EndElement:
|
||||
if t == start.End() {
|
||||
ic.Content = strings.TrimSpace(content.String())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ var (
|
||||
"aside": {},
|
||||
"audio": {"src"},
|
||||
"blockquote": {},
|
||||
"b": {},
|
||||
"br": {},
|
||||
"caption": {},
|
||||
"cite": {},
|
||||
@@ -66,11 +67,12 @@ var (
|
||||
"sup": {"id"},
|
||||
"table": {},
|
||||
"td": {"rowspan", "colspan"},
|
||||
"tfooter": {},
|
||||
"tfoot": {},
|
||||
"th": {"rowspan", "colspan"},
|
||||
"thead": {},
|
||||
"time": {"datetime"},
|
||||
"tr": {},
|
||||
"u": {},
|
||||
"ul": {"id"},
|
||||
"var": {},
|
||||
"video": {"poster", "height", "width", "src"},
|
||||
@@ -127,9 +129,11 @@ func Sanitize(baseURL, input string) string {
|
||||
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
|
||||
if hasRequiredAttributes(tagName, attrNames) {
|
||||
if len(attrNames) > 0 {
|
||||
// Rewrite the start tag with allowed attributes.
|
||||
buffer.WriteString("<" + tagName + " " + htmlAttributes + ">")
|
||||
} else {
|
||||
buffer.WriteString(token.String())
|
||||
// Rewrite the start tag without any attributes.
|
||||
buffer.WriteString("<" + tagName + ">")
|
||||
}
|
||||
|
||||
tagStack = append(tagStack, tagName)
|
||||
@@ -138,7 +142,7 @@ func Sanitize(baseURL, input string) string {
|
||||
case html.EndTagToken:
|
||||
if len(blockedStack) == 0 {
|
||||
if isValidTag(tagName) && slices.Contains(tagStack, tagName) {
|
||||
buffer.WriteString(token.String())
|
||||
buffer.WriteString("</" + tagName + ">")
|
||||
}
|
||||
} else {
|
||||
if blockedStack[len(blockedStack)-1] == tagName {
|
||||
@@ -155,7 +159,7 @@ func Sanitize(baseURL, input string) string {
|
||||
if len(attrNames) > 0 {
|
||||
buffer.WriteString("<" + tagName + " " + htmlAttributes + "/>")
|
||||
} else {
|
||||
buffer.WriteString(token.String())
|
||||
buffer.WriteString("<" + tagName + "/>")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,9 +283,10 @@ func isPixelTracker(tagName string, attributes []html.Attribute) bool {
|
||||
|
||||
for _, attribute := range attributes {
|
||||
if attribute.Val == "1" {
|
||||
if attribute.Key == "height" {
|
||||
switch attribute.Key {
|
||||
case "height":
|
||||
hasHeight = true
|
||||
} else if attribute.Key == "width" {
|
||||
case "width":
|
||||
hasWidth = true
|
||||
}
|
||||
}
|
||||
@@ -294,9 +299,9 @@ func hasRequiredAttributes(tagName string, attributes []string) bool {
|
||||
switch tagName {
|
||||
case "a":
|
||||
return slices.Contains(attributes, "href")
|
||||
case "iframe", "img":
|
||||
case "iframe":
|
||||
return slices.Contains(attributes, "src")
|
||||
case "source":
|
||||
case "source", "img":
|
||||
return slices.Contains(attributes, "src") || slices.Contains(attributes, "srcset")
|
||||
default:
|
||||
return true
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user