mirror of
https://github.com/Athou/commafeed.git
synced 2026-11-03 10:54:46 +00:00
Compare commits
9 Commits
renovate/r
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69cf674a02 | ||
|
|
29987a460d | ||
|
|
fc8cd9c0a2 | ||
|
|
44c2893fff | ||
|
|
2e66fd4702 | ||
|
|
01ac8d6ebd | ||
|
|
ddff408afe | ||
|
|
0c280c4dc4 | ||
|
|
ca4642f7c9 |
@@ -4,7 +4,7 @@
|
|||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"indentWidth": 4,
|
"indentWidth": 4,
|
||||||
"lineEnding": "lf",
|
"lineEnding": "lf",
|
||||||
"lineWidth": 140
|
"lineWidth": 120
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|||||||
274
commafeed-client/package-lock.json
generated
274
commafeed-client/package-lock.json
generated
@@ -163,13 +163,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "7.29.8",
|
"version": "7.29.12",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz",
|
||||||
"integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
|
"integrity": "sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.8",
|
"@babel/parser": "^7.29.12",
|
||||||
"@babel/types": "^7.29.8",
|
"@babel/types": "^7.29.11",
|
||||||
"@jridgewell/gen-mapping": "^0.3.12",
|
"@jridgewell/gen-mapping": "^0.3.12",
|
||||||
"@jridgewell/trace-mapping": "^0.3.28",
|
"@jridgewell/trace-mapping": "^0.3.28",
|
||||||
"jsesc": "^3.0.2"
|
"jsesc": "^3.0.2"
|
||||||
@@ -283,9 +283,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.29.11",
|
"version": "7.29.12",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz",
|
||||||
"integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==",
|
"integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.29.11"
|
"@babel/types": "^7.29.11"
|
||||||
@@ -321,17 +321,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/traverse": {
|
"node_modules/@babel/traverse": {
|
||||||
"version": "7.29.10",
|
"version": "7.29.12",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.12.tgz",
|
||||||
"integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==",
|
"integrity": "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.8",
|
"@babel/generator": "^7.29.12",
|
||||||
"@babel/helper-globals": "^7.29.7",
|
"@babel/helper-globals": "^7.29.7",
|
||||||
"@babel/parser": "^7.29.9",
|
"@babel/parser": "^7.29.12",
|
||||||
"@babel/template": "^7.29.7",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.29.8",
|
"@babel/types": "^7.29.11",
|
||||||
"debug": "^4.3.1"
|
"debug": "^4.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -631,9 +631,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-color-parser": {
|
"node_modules/@csstools/css-color-parser": {
|
||||||
"version": "4.2.6",
|
"version": "4.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz",
|
||||||
"integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==",
|
"integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -682,9 +682,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz",
|
||||||
"integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==",
|
"integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1581,9 +1581,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.155.0",
|
"version": "0.156.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz",
|
||||||
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
|
"integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/oxc-project"
|
"url": "https://github.com/sponsors/oxc-project"
|
||||||
@@ -1679,9 +1679,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz",
|
||||||
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
|
"integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1695,9 +1695,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz",
|
||||||
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
|
"integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1711,9 +1711,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz",
|
||||||
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
|
"integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1727,9 +1727,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz",
|
||||||
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
|
"integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1743,9 +1743,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz",
|
||||||
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
|
"integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1759,9 +1759,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz",
|
||||||
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
|
"integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1775,9 +1775,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz",
|
||||||
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
|
"integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1794,9 +1794,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz",
|
||||||
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
|
"integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1813,9 +1813,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz",
|
||||||
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
|
"integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1832,9 +1832,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz",
|
||||||
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
|
"integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1851,9 +1851,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz",
|
||||||
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
|
"integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1870,9 +1870,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz",
|
||||||
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
|
"integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1889,9 +1889,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz",
|
||||||
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
|
"integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1905,9 +1905,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz",
|
||||||
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
|
"integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1921,9 +1921,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz",
|
||||||
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
|
"integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2158,9 +2158,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz",
|
||||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
"integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -2202,9 +2202,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "26.6.5",
|
"version": "26.6.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
|
||||||
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
|
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~8.9.0"
|
"undici-types": "~8.9.0"
|
||||||
@@ -2796,9 +2796,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.11.33",
|
"version": "2.11.34",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.33.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz",
|
||||||
"integrity": "sha512-yiVKDX+qCMRKBE084lXvq9EcwFUhKZ2nU8TK1EQQuOgPNR/lmbGxXOeDdlGUqC2Zz11Cs9rk1d/8NfgBmlkt0w==",
|
"integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"baseline-browser-mapping": "dist/cli.cjs"
|
"baseline-browser-mapping": "dist/cli.cjs"
|
||||||
@@ -2831,9 +2831,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.29.4",
|
"version": "4.29.5",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz",
|
||||||
"integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==",
|
"integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -2850,10 +2850,10 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.11.29",
|
"baseline-browser-mapping": "^2.11.33",
|
||||||
"caniuse-lite": "^1.0.30001816",
|
"caniuse-lite": "^1.0.30001819",
|
||||||
"electron-to-chromium": "^1.5.459",
|
"electron-to-chromium": "^1.5.464",
|
||||||
"node-releases": "^2.0.59",
|
"node-releases": "^2.0.60",
|
||||||
"update-browserslist-db": "^1.3.4"
|
"update-browserslist-db": "^1.3.4"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2898,9 +2898,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001819",
|
"version": "1.0.30001820",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001820.tgz",
|
||||||
"integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==",
|
"integrity": "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -3245,9 +3245,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.464",
|
"version": "1.5.472",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.472.tgz",
|
||||||
"integrity": "sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==",
|
"integrity": "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
@@ -3258,9 +3258,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "8.1.0",
|
"version": "9.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-9.0.0.tgz",
|
||||||
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
|
"integrity": "sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -4274,9 +4274,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "1.4.3",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz",
|
||||||
"integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
|
"integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4430,9 +4430,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.60",
|
"version": "2.0.63",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.63.tgz",
|
||||||
"integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==",
|
"integrity": "sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -4536,9 +4536,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ora/node_modules/ansi-regex": {
|
"node_modules/ora/node_modules/ansi-regex": {
|
||||||
"version": "6.4.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz",
|
||||||
"integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
|
"integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -4625,13 +4625,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/parse5": {
|
"node_modules/parse5": {
|
||||||
"version": "8.0.1",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.1.0.tgz",
|
||||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
"integrity": "sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"entities": "^8.0.0"
|
"entities": "^9.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
@@ -4694,9 +4694,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.31",
|
"version": "8.5.32",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
|
||||||
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
|
"integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -5228,12 +5228,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz",
|
||||||
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
|
"integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.155.0",
|
"@oxc-project/types": "=0.156.0",
|
||||||
"@rolldown/pluginutils": "^1.0.0"
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -5243,21 +5243,21 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm-eabi": "1.2.15",
|
"@rolldown/binding-android-arm-eabi": "1.2.16",
|
||||||
"@rolldown/binding-android-arm64": "1.2.15",
|
"@rolldown/binding-android-arm64": "1.2.16",
|
||||||
"@rolldown/binding-darwin-arm64": "1.2.15",
|
"@rolldown/binding-darwin-arm64": "1.2.16",
|
||||||
"@rolldown/binding-darwin-x64": "1.2.15",
|
"@rolldown/binding-darwin-x64": "1.2.16",
|
||||||
"@rolldown/binding-freebsd-x64": "1.2.15",
|
"@rolldown/binding-freebsd-x64": "1.2.16",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.2.16",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
|
"@rolldown/binding-linux-arm64-gnu": "1.2.16",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.2.15",
|
"@rolldown/binding-linux-arm64-musl": "1.2.16",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
|
"@rolldown/binding-linux-ppc64-gnu": "1.2.16",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
|
"@rolldown/binding-linux-s390x-gnu": "1.2.16",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.2.15",
|
"@rolldown/binding-linux-x64-gnu": "1.2.16",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.2.15",
|
"@rolldown/binding-linux-x64-musl": "1.2.16",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.2.15",
|
"@rolldown/binding-openharmony-arm64": "1.2.16",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
|
"@rolldown/binding-win32-arm64-msvc": "1.2.16",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.2.15"
|
"@rolldown/binding-win32-x64-msvc": "1.2.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/saxes": {
|
"node_modules/saxes": {
|
||||||
@@ -5467,9 +5467,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz",
|
||||||
"integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==",
|
"integrity": "sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5533,9 +5533,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tldts-core": {
|
"node_modules/tldts-core": {
|
||||||
"version": "7.4.19",
|
"version": "7.4.20",
|
||||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz",
|
||||||
"integrity": "sha512-goNXV6Ho9TIOfpJZanTd6CKG5DyYgYAL+k+22T0SLh8l6+KHIfBUPFuePuSZv05UttIaaLmI9tb7TmGIdK5AkQ==",
|
"integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -5579,9 +5579,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/tss-react": {
|
"node_modules/tss-react": {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@react-querybuilder/mantine": "8.26.0",
|
"@react-querybuilder/mantine": "8.26.0",
|
||||||
"@reduxjs/toolkit": "2.13.0",
|
"@reduxjs/toolkit": "2.13.0",
|
||||||
"@rolldown/plugin-babel": "0.3.0",
|
"@rolldown/plugin-babel": "0.2.4",
|
||||||
"axios": "1.21.0",
|
"axios": "1.21.0",
|
||||||
"dayjs": "1.11.23",
|
"dayjs": "1.11.23",
|
||||||
"escape-string-regexp": "5.0.0",
|
"escape-string-regexp": "5.0.0",
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ function Providers(
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}>
|
}>
|
||||||
) {
|
) {
|
||||||
const primaryColor = useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
|
const primaryColor =
|
||||||
|
useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
|
||||||
return (
|
return (
|
||||||
<I18nProvider i18n={i18n}>
|
<I18nProvider i18n={i18n}>
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
@@ -87,7 +88,10 @@ function AppRoutes() {
|
|||||||
<Route path="register" element={<RegistrationPage />} />
|
<Route path="register" element={<RegistrationPage />} />
|
||||||
<Route path="passwordRecovery" element={<PasswordRecoveryPage />} />
|
<Route path="passwordRecovery" element={<PasswordRecoveryPage />} />
|
||||||
<Route path="passwordReset" element={<PasswordResetPage />} />
|
<Route path="passwordReset" element={<PasswordResetPage />} />
|
||||||
<Route path="app" element={<Layout header={<Header />} sidebar={<Tree />} sidebarVisible={sidebarVisible} />}>
|
<Route
|
||||||
|
path="app"
|
||||||
|
element={<Layout header={<Header />} sidebar={<Tree />} sidebarVisible={sidebarVisible} />}
|
||||||
|
>
|
||||||
<Route path="category">
|
<Route path="category">
|
||||||
<Route path=":id" element={<FeedEntriesPage sourceType="category" />} />
|
<Route path=":id" element={<FeedEntriesPage sourceType="category" />} />
|
||||||
<Route path=":id/details" element={<CategoryDetailsPage />} />
|
<Route path=":id/details" element={<CategoryDetailsPage />} />
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export const client = {
|
|||||||
getRoot: async () => await axiosInstance.get<Category>("category/get"),
|
getRoot: async () => await axiosInstance.get<Category>("category/get"),
|
||||||
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
|
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
|
||||||
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
|
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
|
||||||
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("category/entries", { params: req }),
|
getEntries: async (req: GetEntriesPaginatedRequest) =>
|
||||||
|
await axiosInstance.get<Entries>("category/entries", { params: req }),
|
||||||
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
|
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
|
||||||
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
|
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
|
||||||
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
|
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
|
||||||
@@ -76,7 +77,8 @@ export const client = {
|
|||||||
feed: {
|
feed: {
|
||||||
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
|
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
|
||||||
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
|
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
|
||||||
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("feed/entries", { params: req }),
|
getEntries: async (req: GetEntriesPaginatedRequest) =>
|
||||||
|
await axiosInstance.get<Entries>("feed/entries", { params: req }),
|
||||||
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
|
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
|
||||||
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
|
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
|
||||||
refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
|
refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
|
||||||
@@ -107,7 +109,8 @@ export const client = {
|
|||||||
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
|
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
|
||||||
initialSetup: async (req: InitialSetupRequest) => await axiosInstance.post("user/initialSetup", req),
|
initialSetup: async (req: InitialSetupRequest) => await axiosInstance.post("user/initialSetup", req),
|
||||||
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
|
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
|
||||||
passwordResetCallback: async (req: PasswordResetConfirmationRequest) => await axiosInstance.post("user/passwordResetCallback", req),
|
passwordResetCallback: async (req: PasswordResetConfirmationRequest) =>
|
||||||
|
await axiosInstance.post("user/passwordResetCallback", req),
|
||||||
getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
|
getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
|
||||||
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
|
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
|
||||||
sendTestPushNotification: async (settings: PushNotificationSettings) =>
|
sendTestPushNotification: async (settings: PushNotificationSettings) =>
|
||||||
@@ -156,9 +159,13 @@ function isCommaFeedApplicationError(err: AxiosError): err is AxiosError<CommaFe
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> {
|
function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> {
|
||||||
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
|
return (
|
||||||
|
!!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> {
|
function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> {
|
||||||
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
|
return (
|
||||||
|
!!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||||
import { Constants } from "@/app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry, markMultipleEntries, starEntry, tagEntry } from "@/app/entries/thunks"
|
import {
|
||||||
|
loadEntries,
|
||||||
|
loadMoreEntries,
|
||||||
|
markAllEntries,
|
||||||
|
markEntry,
|
||||||
|
markMultipleEntries,
|
||||||
|
starEntry,
|
||||||
|
tagEntry,
|
||||||
|
} from "@/app/entries/thunks"
|
||||||
import type { Entry } from "@/app/types"
|
import type { Entry } from "@/app/types"
|
||||||
|
|
||||||
export type EntrySourceType = "category" | "feed" | "tag"
|
export type EntrySourceType = "category" | "feed" | "tag"
|
||||||
@@ -85,12 +93,16 @@ export const entriesSlice = createSlice({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
builder.addCase(markAllEntries.pending, (state, action) => {
|
builder.addCase(markAllEntries.pending, (state, action) => {
|
||||||
for (const e of state.entries.filter(e => (action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true))) {
|
for (const e of state.entries.filter(e =>
|
||||||
|
action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true
|
||||||
|
)) {
|
||||||
e.read = true
|
e.read = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
builder.addCase(starEntry.pending, (state, action) => {
|
builder.addCase(starEntry.pending, (state, action) => {
|
||||||
for (const e of state.entries.filter(e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId)) {
|
for (const e of state.entries.filter(
|
||||||
|
e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId
|
||||||
|
)) {
|
||||||
e.starred = action.meta.arg.starred
|
e.starred = action.meta.arg.starred
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_,
|
|||||||
const state = thunkApi.getState()
|
const state = thunkApi.getState()
|
||||||
const { source } = state.entries
|
const { source } = state.entries
|
||||||
const offset =
|
const offset =
|
||||||
state.user.settings?.readingMode === "all" || state.entries.search || (source.type === "category" && source.id === "starred")
|
state.user.settings?.readingMode === "all" ||
|
||||||
|
state.entries.search ||
|
||||||
|
(source.type === "category" && source.id === "starred")
|
||||||
? state.entries.entries.length
|
? state.entries.entries.length
|
||||||
: state.entries.entries.filter(e => !e.read).length
|
: state.entries.entries.filter(e => !e.read).length
|
||||||
const endpoint = getEndpoint(state.entries.source.type)
|
const endpoint = getEndpoint(state.entries.source.type)
|
||||||
@@ -220,7 +222,9 @@ export const selectEntry = createAppAsyncThunk(
|
|||||||
|
|
||||||
const entryIndex = state.entries.entries.indexOf(entry)
|
const entryIndex = state.entries.entries.indexOf(entry)
|
||||||
const entriesToKeepOnTopWhenScrolling =
|
const entriesToKeepOnTopWhenScrolling =
|
||||||
viewMode === "expanded" ? 0 : Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
|
viewMode === "expanded"
|
||||||
|
? 0
|
||||||
|
: Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
|
||||||
const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
|
const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
|
||||||
|
|
||||||
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
||||||
@@ -228,7 +232,8 @@ export const selectEntry = createAppAsyncThunk(
|
|||||||
if (entryElement && entryElementToScrollTo) {
|
if (entryElement && entryElementToScrollTo) {
|
||||||
const scrollMode = state.user.settings?.scrollMode
|
const scrollMode = state.user.settings?.scrollMode
|
||||||
const entryEntirelyVisible =
|
const entryEntirelyVisible =
|
||||||
Constants.layout.isTopVisible(entryElementToScrollTo) && Constants.layout.isBottomVisible(entryElement)
|
Constants.layout.isTopVisible(entryElementToScrollTo) &&
|
||||||
|
Constants.layout.isBottomVisible(entryElement)
|
||||||
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
|
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
|
||||||
const scrollSpeed = state.user.settings?.scrollSpeed
|
const scrollSpeed = state.user.settings?.scrollSpeed
|
||||||
const margin = viewMode === "detailed" ? 8 : 3
|
const margin = viewMode === "detailed" ? 8 : 3
|
||||||
@@ -242,7 +247,12 @@ export const selectEntry = createAppAsyncThunk(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const scrollToEntry = (entryElement: HTMLElement, margin: number, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
|
const scrollToEntry = (
|
||||||
|
entryElement: HTMLElement,
|
||||||
|
margin: number,
|
||||||
|
scrollSpeed: number | undefined,
|
||||||
|
onScrollEnded: () => void
|
||||||
|
) => {
|
||||||
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
|
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
|
||||||
const offset = (header?.bottom ?? 0) + margin
|
const offset = (header?.bottom ?? 0) + margin
|
||||||
scrollToWithCallback({
|
scrollToWithCallback({
|
||||||
|
|||||||
@@ -2,11 +2,17 @@ import { createAppAsyncThunk } from "@/app/async-thunk"
|
|||||||
import { Constants } from "@/app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
import { redirectTo } from "@/app/redirect/slice"
|
import { redirectTo } from "@/app/redirect/slice"
|
||||||
|
|
||||||
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) => thunkApi.dispatch(redirectTo("/login")))
|
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/login"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) => thunkApi.dispatch(redirectTo("/register")))
|
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/register"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToInitialSetup = createAppAsyncThunk("redirect/initialSetup", (_, thunkApi) => thunkApi.dispatch(redirectTo("/setup")))
|
export const redirectToInitialSetup = createAppAsyncThunk("redirect/initialSetup", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/setup"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", () => {
|
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", () => {
|
||||||
window.location.href = "api-documentation/"
|
window.location.href = "api-documentation/"
|
||||||
@@ -38,15 +44,21 @@ export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details"
|
|||||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
|
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) => thunkApi.dispatch(redirectTo(`/app/tag/${id}`)))
|
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo(`/app/tag/${id}`))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
|
export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
|
||||||
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
|
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/add")))
|
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/add"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/settings")))
|
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/settings"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
|
export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
|
||||||
thunkApi.dispatch(redirectTo("/app/admin/users"))
|
thunkApi.dispatch(redirectTo("/app/admin/users"))
|
||||||
@@ -56,6 +68,10 @@ export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (
|
|||||||
thunkApi.dispatch(redirectTo("/app/admin/metrics"))
|
thunkApi.dispatch(redirectTo("/app/admin/metrics"))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/donate")))
|
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/donate"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/about")))
|
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/about"))
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { createAppAsyncThunk } from "@/app/async-thunk"
|
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||||
import { client } from "@/app/client"
|
import { client } from "@/app/client"
|
||||||
|
|
||||||
export const reloadServerInfos = createAppAsyncThunk("server/infos", async () => await client.server.getServerInfos().then(r => r.data))
|
export const reloadServerInfos = createAppAsyncThunk(
|
||||||
|
"server/infos",
|
||||||
|
async () => await client.server.getServerInfos().then(r => r.data)
|
||||||
|
)
|
||||||
|
|||||||
@@ -41,4 +41,5 @@ export type AppDispatch = typeof store.dispatch
|
|||||||
|
|
||||||
export const useAppDispatch: () => AppDispatch = useDispatch
|
export const useAppDispatch: () => AppDispatch = useDispatch
|
||||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
||||||
export const useShallowEqualAppSelector: TypedUseSelectorHook<RootState> = selector => useSelector(selector, shallowEqual)
|
export const useShallowEqualAppSelector: TypedUseSelectorHook<RootState> = selector =>
|
||||||
|
useSelector(selector, shallowEqual)
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { incrementUnreadCount } from "@/app/tree/slice"
|
|||||||
import type { CollapseRequest, Subscription } from "@/app/types"
|
import type { CollapseRequest, Subscription } from "@/app/types"
|
||||||
import { flattenCategoryTree, visitCategoryTree } from "@/app/utils"
|
import { flattenCategoryTree, visitCategoryTree } from "@/app/utils"
|
||||||
|
|
||||||
export const reloadTree = createAppAsyncThunk("tree/reload", async () => await client.category.getRoot().then(r => r.data))
|
export const reloadTree = createAppAsyncThunk(
|
||||||
|
"tree/reload",
|
||||||
|
async () => await client.category.getRoot().then(r => r.data)
|
||||||
|
)
|
||||||
|
|
||||||
export const collapseTreeCategory = createAppAsyncThunk(
|
export const collapseTreeCategory = createAppAsyncThunk(
|
||||||
"tree/category/collapse",
|
"tree/category/collapse",
|
||||||
|
|||||||
@@ -2,14 +2,26 @@ import { createAppAsyncThunk } from "@/app/async-thunk"
|
|||||||
import { client } from "@/app/client"
|
import { client } from "@/app/client"
|
||||||
import type { Settings } from "@/app/types"
|
import type { Settings } from "@/app/types"
|
||||||
|
|
||||||
export const reloadSettings = createAppAsyncThunk("settings/reload", async () => await client.user.getSettings().then(r => r.data))
|
export const reloadSettings = createAppAsyncThunk(
|
||||||
|
"settings/reload",
|
||||||
|
async () => await client.user.getSettings().then(r => r.data)
|
||||||
|
)
|
||||||
|
|
||||||
export const reloadProfile = createAppAsyncThunk("profile/reload", async () => await client.user.getProfile().then(r => r.data))
|
export const reloadProfile = createAppAsyncThunk(
|
||||||
|
"profile/reload",
|
||||||
|
async () => await client.user.getProfile().then(r => r.data)
|
||||||
|
)
|
||||||
|
|
||||||
export const reloadTags = createAppAsyncThunk("entries/tags", async () => await client.entry.getTags().then(r => r.data))
|
export const reloadTags = createAppAsyncThunk(
|
||||||
|
"entries/tags",
|
||||||
|
async () => await client.entry.getTags().then(r => r.data)
|
||||||
|
)
|
||||||
|
|
||||||
export const changeSettings = createAppAsyncThunk("settings/change", async (newSettings: Partial<Settings>, thunkApi) => {
|
export const changeSettings = createAppAsyncThunk(
|
||||||
const { settings } = thunkApi.getState().user
|
"settings/change",
|
||||||
if (!settings) return
|
async (newSettings: Partial<Settings>, thunkApi) => {
|
||||||
await client.user.saveSettings({ ...settings, ...newSettings })
|
const { settings } = thunkApi.getState().user
|
||||||
})
|
if (!settings) return
|
||||||
|
await client.user.saveSettings({ ...settings, ...newSettings })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -43,13 +43,27 @@ export function categoryHasNewEntries(category?: TreeCategory): boolean {
|
|||||||
.some(f => f.hasNewEntries)
|
.some(f => f.hasNewEntries)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => {
|
export const calculatePlaceholderSize = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
maxWidth,
|
||||||
|
}: {
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
maxWidth: number
|
||||||
|
}) => {
|
||||||
const placeholderWidth = width && Math.min(width, maxWidth)
|
const placeholderWidth = width && Math.min(width, maxWidth)
|
||||||
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
|
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
|
||||||
return { width: placeholderWidth, height: placeholderHeight }
|
return { width: placeholderWidth, height: placeholderHeight }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const scrollToWithCallback = ({ options, onScrollEnded }: { options: ScrollToOptions; onScrollEnded: () => void }) => {
|
export const scrollToWithCallback = ({
|
||||||
|
options,
|
||||||
|
onScrollEnded,
|
||||||
|
}: {
|
||||||
|
options: ScrollToOptions
|
||||||
|
onScrollEnded: () => void
|
||||||
|
}) => {
|
||||||
const offset = (options.top ?? 0).toFixed()
|
const offset = (options.top ?? 0).toFixed()
|
||||||
|
|
||||||
const onScroll = throttle(100, () => {
|
const onScroll = throttle(100, () => {
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { MessageDescriptor } from "@lingui/core"
|
import type { MessageDescriptor } from "@lingui/core"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { ActionIcon, type ActionIconVariant, Box, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
type ActionIconVariant,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
type ButtonVariant,
|
||||||
|
Tooltip,
|
||||||
|
useMantineTheme,
|
||||||
|
} from "@mantine/core"
|
||||||
import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
|
import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
|
||||||
import { Constants } from "@/app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
import { useActionButton } from "@/hooks/useActionButton"
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export function MarkAllAsReadConfirmationDialog() {
|
|||||||
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
|
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
|
||||||
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now()
|
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now()
|
||||||
const search = useAppSelector(state => state.entries.search)
|
const search = useAppSelector(state => state.entries.search)
|
||||||
const markAllAsReadNavigateToNextUnread = useAppSelector(state => state.user.settings?.markAllAsReadNavigateToNextUnread)
|
const markAllAsReadNavigateToNextUnread = useAppSelector(
|
||||||
|
state => state.user.settings?.markAllAsReadNavigateToNextUnread
|
||||||
|
)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
@@ -33,7 +35,8 @@ export function MarkAllAsReadConfirmationDialog() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isAllCategorySelected = source.type === "category" && source.id === Constants.categories.all.id
|
const isAllCategorySelected = source.type === "category" && source.id === Constants.categories.all.id
|
||||||
if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected) await dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected)
|
||||||
|
await dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -51,7 +54,8 @@ export function MarkAllAsReadConfirmationDialog() {
|
|||||||
)}
|
)}
|
||||||
{threshold > 0 && (
|
{threshold > 0 && (
|
||||||
<Trans>
|
<Trans>
|
||||||
Are you sure you want to mark entries older than {threshold} days of <Code>{sourceLabel}</Code> as read?
|
Are you sure you want to mark entries older than {threshold} days of{" "}
|
||||||
|
<Code>{sourceLabel}</Code> as read?
|
||||||
</Trans>
|
</Trans>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -15,5 +15,12 @@ export const ReceivePushNotificationsChechbox = (props: CheckboxProps) => {
|
|||||||
description = <Trans>Push notifications are not configured in your user settings.</Trans>
|
description = <Trans>Push notifications are not configured in your user settings.</Trans>
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Checkbox label={<Trans>Receive push notifications</Trans>} disabled={disabled} description={description} {...props} />
|
return (
|
||||||
|
<Checkbox
|
||||||
|
label={<Trans>Receive push notifications</Trans>}
|
||||||
|
disabled={disabled}
|
||||||
|
description={description}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ export function UserEdit(props: Readonly<UserEditProps>) {
|
|||||||
<form onSubmit={form.onSubmit(saveUser.execute)}>
|
<form onSubmit={form.onSubmit(saveUser.execute)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
||||||
<PasswordInput label={<Trans>Password</Trans>} {...form.getInputProps("password")} required={!props.user} />
|
<PasswordInput
|
||||||
|
label={<Trans>Password</Trans>}
|
||||||
|
{...form.getInputProps("password")}
|
||||||
|
required={!props.user}
|
||||||
|
/>
|
||||||
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} />
|
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} />
|
||||||
<Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} />
|
<Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} />
|
||||||
<Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} />
|
<Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} />
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ describe("Content component", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("renders iframe tag when included in content", () => {
|
it("renders iframe tag when included in content", () => {
|
||||||
const { container } = render(<Content content='<iframe src="https://example.com"></iframe>' />, { wrapper: MantineProvider })
|
const { container } = render(<Content content='<iframe src="https://example.com"></iframe>' />, {
|
||||||
|
wrapper: MantineProvider,
|
||||||
|
})
|
||||||
expect(container.querySelector("iframe")).toHaveAttribute("src", "https://example.com")
|
expect(container.querySelector("iframe")).toHaveAttribute("src", "https://example.com")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { Box, Mark } from "@mantine/core"
|
import { Box, Mark } from "@mantine/core"
|
||||||
import escapeStringRegexp from "escape-string-regexp"
|
import escapeStringRegexp from "escape-string-regexp"
|
||||||
import { ALLOWED_TAG_LIST, type ChildrenNode, Interweave, Matcher, type MatchResponse, type Node, type TransformCallback } from "interweave"
|
import {
|
||||||
|
ALLOWED_TAG_LIST,
|
||||||
|
type ChildrenNode,
|
||||||
|
Interweave,
|
||||||
|
Matcher,
|
||||||
|
type MatchResponse,
|
||||||
|
type Node,
|
||||||
|
type TransformCallback,
|
||||||
|
} from "interweave"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import styleToObject from "style-to-object"
|
import styleToObject from "style-to-object"
|
||||||
import { Constants } from "@/app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
|
|||||||
@@ -300,7 +300,9 @@ export function FeedEntries() {
|
|||||||
entry={entry}
|
entry={entry}
|
||||||
expanded={!!entry.expanded || viewMode === "expanded"}
|
expanded={!!entry.expanded || viewMode === "expanded"}
|
||||||
selected={entry.id === selectedEntryId}
|
selected={entry.id === selectedEntryId}
|
||||||
showSelectionIndicator={entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")}
|
showSelectionIndicator={
|
||||||
|
entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")
|
||||||
|
}
|
||||||
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
|
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
|
||||||
onHeaderClick={event => headerClicked(entry, event)}
|
onHeaderClick={event => headerClicked(entry, event)}
|
||||||
onHeaderRightClick={event => headerRightClicked(entry, event)}
|
onHeaderRightClick={event => headerRightClicked(entry, event)}
|
||||||
|
|||||||
@@ -34,67 +34,80 @@ const useStyles = tss
|
|||||||
maxWidth?: number
|
maxWidth?: number
|
||||||
fontSizePercentage: number
|
fontSizePercentage: number
|
||||||
}>()
|
}>()
|
||||||
.create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth, fontSizePercentage }) => {
|
.create(
|
||||||
let backgroundColor: string
|
({
|
||||||
if (colorScheme === "dark") {
|
theme,
|
||||||
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
colorScheme,
|
||||||
} else {
|
read,
|
||||||
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
|
expanded,
|
||||||
}
|
viewMode,
|
||||||
|
rtl,
|
||||||
|
showSelectionIndicator,
|
||||||
|
maxWidth,
|
||||||
|
fontSizePercentage,
|
||||||
|
}) => {
|
||||||
|
let backgroundColor: string
|
||||||
|
if (colorScheme === "dark") {
|
||||||
|
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
||||||
|
} else {
|
||||||
|
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
|
||||||
|
}
|
||||||
|
|
||||||
let marginY = 10
|
let marginY = 10
|
||||||
if (viewMode === "title") {
|
if (viewMode === "title") {
|
||||||
marginY = 2
|
marginY = 2
|
||||||
} else if (viewMode === "cozy") {
|
} else if (viewMode === "cozy") {
|
||||||
marginY = 6
|
marginY = 6
|
||||||
}
|
}
|
||||||
|
|
||||||
let mobileMarginY = 6
|
let mobileMarginY = 6
|
||||||
if (viewMode === "title") {
|
if (viewMode === "title") {
|
||||||
mobileMarginY = 2
|
mobileMarginY = 2
|
||||||
} else if (viewMode === "cozy") {
|
} else if (viewMode === "cozy") {
|
||||||
mobileMarginY = 4
|
mobileMarginY = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
let backgroundHoverColor = backgroundColor
|
let backgroundHoverColor = backgroundColor
|
||||||
if (!expanded && !read) {
|
if (!expanded && !read) {
|
||||||
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
|
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
let paperBorderLeftColor = ""
|
let paperBorderLeftColor = ""
|
||||||
if (showSelectionIndicator) {
|
if (showSelectionIndicator) {
|
||||||
const borderLeftColor = colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
|
const borderLeftColor =
|
||||||
paperBorderLeftColor = `${borderLeftColor} !important`
|
colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
|
||||||
}
|
paperBorderLeftColor = `${borderLeftColor} !important`
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paper: {
|
paper: {
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
borderLeftColor: paperBorderLeftColor,
|
borderLeftColor: paperBorderLeftColor,
|
||||||
marginTop: marginY,
|
marginTop: marginY,
|
||||||
marginBottom: marginY,
|
marginBottom: marginY,
|
||||||
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
|
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
|
||||||
marginTop: mobileMarginY,
|
marginTop: mobileMarginY,
|
||||||
marginBottom: mobileMarginY,
|
marginBottom: mobileMarginY,
|
||||||
},
|
},
|
||||||
"@media (hover: hover)": {
|
"@media (hover: hover)": {
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
backgroundColor: backgroundHoverColor,
|
backgroundColor: backgroundHoverColor,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
headerLink: {
|
||||||
headerLink: {
|
fontSize: `${fontSizePercentage}%`,
|
||||||
fontSize: `${fontSizePercentage}%`,
|
color: "inherit",
|
||||||
color: "inherit",
|
textDecoration: "none",
|
||||||
textDecoration: "none",
|
},
|
||||||
},
|
body: {
|
||||||
body: {
|
fontSize: `${fontSizePercentage}%`,
|
||||||
fontSize: `${fontSizePercentage}%`,
|
direction: rtl ? "rtl" : "ltr",
|
||||||
direction: rtl ? "rtl" : "ltr",
|
maxWidth: maxWidth ?? "100%",
|
||||||
maxWidth: maxWidth ?? "100%",
|
},
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
export function FeedEntry(props: Readonly<FeedEntryProps>) {
|
export function FeedEntry(props: Readonly<FeedEntryProps>) {
|
||||||
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
||||||
@@ -116,7 +129,9 @@ export function FeedEntry(props: Readonly<FeedEntryProps>) {
|
|||||||
const showExternalLinkIcon =
|
const showExternalLinkIcon =
|
||||||
externalLinkDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(externalLinkDisplayMode)
|
externalLinkDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(externalLinkDisplayMode)
|
||||||
const showStarIcon =
|
const showStarIcon =
|
||||||
props.entry.markable && starIconDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode)
|
props.entry.markable &&
|
||||||
|
starIconDisplayMode &&
|
||||||
|
["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode)
|
||||||
|
|
||||||
const swipeHandlers = useSwipeable({
|
const swipeHandlers = useSwipeable({
|
||||||
onSwipedLeft: props.onSwipedLeft,
|
onSwipedLeft: props.onSwipedLeft,
|
||||||
|
|||||||
@@ -35,7 +35,12 @@ export function FeedEntryContextMenu(props: Readonly<FeedEntryContextMenuProps>)
|
|||||||
const { openLinkInBackgroundTab } = useBrowserExtension()
|
const { openLinkInBackgroundTab } = useBrowserExtension()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu id={Constants.dom.entryContextMenuId(props.entry)} theme={colorScheme} animation={false} className={classes.menu}>
|
<Menu
|
||||||
|
id={Constants.dom.entryContextMenuId(props.entry)}
|
||||||
|
theme={colorScheme}
|
||||||
|
animation={false}
|
||||||
|
className={classes.menu}
|
||||||
|
>
|
||||||
<Item
|
<Item
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.open(props.entry.url, "_blank", "noreferrer")
|
window.open(props.entry.url, "_blank", "noreferrer")
|
||||||
@@ -63,13 +68,19 @@ export function FeedEntryContextMenu(props: Readonly<FeedEntryContextMenuProps>)
|
|||||||
|
|
||||||
{props.entry.markable && (
|
{props.entry.markable && (
|
||||||
<>
|
<>
|
||||||
<Item onClick={async () => await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}>
|
<Item
|
||||||
|
onClick={async () =>
|
||||||
|
await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))
|
||||||
|
}
|
||||||
|
>
|
||||||
<Group>
|
<Group>
|
||||||
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
|
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
|
||||||
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||||
</Group>
|
</Group>
|
||||||
</Item>
|
</Item>
|
||||||
<Item onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}>
|
<Item
|
||||||
|
onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}
|
||||||
|
>
|
||||||
<Group>
|
<Group>
|
||||||
{props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />}
|
{props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />}
|
||||||
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
|
import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
|
||||||
import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb"
|
import {
|
||||||
|
TbArrowBarToDown,
|
||||||
|
TbExternalLink,
|
||||||
|
TbMail,
|
||||||
|
TbMailOpened,
|
||||||
|
TbShare,
|
||||||
|
TbStar,
|
||||||
|
TbStarOff,
|
||||||
|
TbTag,
|
||||||
|
} from "react-icons/tb"
|
||||||
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "@/app/entries/thunks"
|
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "@/app/entries/thunks"
|
||||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import type { Entry } from "@/app/types"
|
import type { Entry } from "@/app/types"
|
||||||
@@ -71,7 +80,12 @@ export function FeedEntryFooter(props: Readonly<FeedEntryFooterProps>) {
|
|||||||
{tags && (
|
{tags && (
|
||||||
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
|
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Indicator label={props.entry.tags.length} disabled={props.entry.tags.length === 0} inline size={16}>
|
<Indicator
|
||||||
|
label={props.entry.tags.length}
|
||||||
|
disabled={props.entry.tags.length === 0}
|
||||||
|
inline
|
||||||
|
size={16}
|
||||||
|
>
|
||||||
<ActionButton icon={<TbTag size={18} />} label={msg`Tags`} />
|
<ActionButton icon={<TbTag size={18} />} label={msg`Tags`} />
|
||||||
</Indicator>
|
</Indicator>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
|
|||||||
@@ -104,7 +104,9 @@ export function ShareButtons(
|
|||||||
}>
|
}>
|
||||||
) {
|
) {
|
||||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
||||||
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(site => sharingSettings?.[site])
|
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(
|
||||||
|
site => sharingSettings?.[site]
|
||||||
|
)
|
||||||
const url = encodeURIComponent(props.url)
|
const url = encodeURIComponent(props.url)
|
||||||
const desc = encodeURIComponent(props.description)
|
const desc = encodeURIComponent(props.description)
|
||||||
const clipboardAvailable = typeof navigator.clipboard !== "undefined"
|
const clipboardAvailable = typeof navigator.clipboard !== "undefined"
|
||||||
@@ -119,7 +121,9 @@ export function ShareButtons(
|
|||||||
{showNativeSection && (
|
{showNativeSection && (
|
||||||
<SimpleGrid cols={4}>
|
<SimpleGrid cols={4}>
|
||||||
{clipboardAvailable && <CopyUrlButton url={props.url} />}
|
{clipboardAvailable && <CopyUrlButton url={props.url} />}
|
||||||
{nativeSharingAvailable && <BrowserNativeShareButton url={props.url} description={props.description} />}
|
{nativeSharingAvailable && (
|
||||||
|
<BrowserNativeShareButton url={props.url} description={props.description} />
|
||||||
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,12 @@ export function AddCategory() {
|
|||||||
|
|
||||||
<form onSubmit={form.onSubmit(addCategory.execute)}>
|
<form onSubmit={form.onSubmit(addCategory.execute)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={<Trans>Category</Trans>} placeholder={_(msg`Category`)} {...form.getInputProps("name")} required />
|
<TextInput
|
||||||
|
label={<Trans>Category</Trans>}
|
||||||
|
placeholder={_(msg`Category`)}
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
<CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable />
|
<CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable />
|
||||||
<Group justify="center">
|
<Group justify="center">
|
||||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ export function ImportOpml() {
|
|||||||
placeholder={_(msg`OPML file`)}
|
placeholder={_(msg`OPML file`)}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
An opml file is an XML file containing feed URLs and categories. You can get an OPML file by exporting your
|
An opml file is an XML file containing feed URLs and categories. You can get an OPML
|
||||||
data from other feed reading services.
|
file by exporting your data from other feed reading services.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
{...form.getInputProps("file")}
|
{...form.getInputProps("file")}
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ export function Subscribe() {
|
|||||||
placeholder="https://www.mysite.com/rss"
|
placeholder="https://www.mysite.com/rss"
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
The URL for the feed you want to subscribe to. You can also use the website's url directly and CommaFeed
|
The URL for the feed you want to subscribe to. You can also use the website's url
|
||||||
will try to find the feed in the page.
|
directly and CommaFeed will try to find the feed in the page.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
@@ -101,8 +101,17 @@ export function Subscribe() {
|
|||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
|
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
|
||||||
<TextInput label={<Trans>Feed name</Trans>} {...step1Form.getInputProps("title")} required autoFocus />
|
<TextInput
|
||||||
<CategorySelect label={<Trans>Category</Trans>} {...step1Form.getInputProps("categoryId")} clearable />
|
label={<Trans>Feed name</Trans>}
|
||||||
|
{...step1Form.getInputProps("title")}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<CategorySelect
|
||||||
|
label={<Trans>Category</Trans>}
|
||||||
|
{...step1Form.getInputProps("categoryId")}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
@@ -117,7 +126,11 @@ export function Subscribe() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{activeStep === 1 && (
|
{activeStep === 1 && (
|
||||||
<Button type="submit" leftSection={<TbRss size={16} />} loading={fetchFeed.loading || subscribe.loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
leftSection={<TbRss size={16} />}
|
||||||
|
loading={fetchFeed.loading || subscribe.loading}
|
||||||
|
>
|
||||||
<Trans>Subscribe</Trans>
|
<Trans>Subscribe</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,7 +26,16 @@ const fields: Field[] = [
|
|||||||
{ name: "categoriesLower", label: "Categories (lower case)" },
|
{ name: "categoriesLower", label: "Categories (lower case)" },
|
||||||
]
|
]
|
||||||
|
|
||||||
const textOperators = new Set(["=", "!=", "contains", "beginsWith", "endsWith", "doesNotContain", "doesNotBeginWith", "doesNotEndWith"])
|
const textOperators = new Set([
|
||||||
|
"=",
|
||||||
|
"!=",
|
||||||
|
"contains",
|
||||||
|
"beginsWith",
|
||||||
|
"endsWith",
|
||||||
|
"doesNotContain",
|
||||||
|
"doesNotBeginWith",
|
||||||
|
"doesNotEndWith",
|
||||||
|
])
|
||||||
|
|
||||||
function toCelString(query: RuleGroupType): string {
|
function toCelString(query: RuleGroupType): string {
|
||||||
if (query.rules.length === 0) {
|
if (query.rules.length === 0) {
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ export function Star(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>} openDelay={Constants.tooltip.delay}>
|
<Tooltip
|
||||||
|
label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||||
|
openDelay={Constants.tooltip.delay}
|
||||||
|
>
|
||||||
<ActionIcon variant="transparent" onClick={onClick}>
|
<ActionIcon variant="transparent" onClick={onClick}>
|
||||||
{props.entry.starred ? <TbStarFilled size={18} /> : <TbStar size={18} />}
|
{props.entry.starred ? <TbStarFilled size={18} /> : <TbStar size={18} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ import {
|
|||||||
TbSortDescending,
|
TbSortDescending,
|
||||||
TbUser,
|
TbUser,
|
||||||
} from "react-icons/tb"
|
} from "react-icons/tb"
|
||||||
import { markAllAsReadWithConfirmationIfRequired, reloadEntries, search, selectNextEntry, selectPreviousEntry } from "@/app/entries/thunks"
|
import {
|
||||||
|
markAllAsReadWithConfirmationIfRequired,
|
||||||
|
reloadEntries,
|
||||||
|
search,
|
||||||
|
selectNextEntry,
|
||||||
|
selectPreviousEntry,
|
||||||
|
} from "@/app/entries/thunks"
|
||||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import { changeSettings } from "@/app/user/thunks"
|
import { changeSettings } from "@/app/user/thunks"
|
||||||
import { ActionButton } from "@/components/ActionButton"
|
import { ActionButton } from "@/components/ActionButton"
|
||||||
@@ -143,7 +149,13 @@ export function Header() {
|
|||||||
onClick={toggleReadingMode}
|
onClick={toggleReadingMode}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={settings.readingOrder === "asc" ? <TbSortAscending size={iconSize} /> : <TbSortDescending size={iconSize} />}
|
icon={
|
||||||
|
settings.readingOrder === "asc" ? (
|
||||||
|
<TbSortAscending size={iconSize} />
|
||||||
|
) : (
|
||||||
|
<TbSortDescending size={iconSize} />
|
||||||
|
)
|
||||||
|
}
|
||||||
label={settings.readingOrder === "asc" ? msg`Asc` : msg`Desc`}
|
label={settings.readingOrder === "asc" ? msg`Asc` : msg`Desc`}
|
||||||
onClick={toggleReadingOrder}
|
onClick={toggleReadingOrder}
|
||||||
/>
|
/>
|
||||||
@@ -160,7 +172,11 @@ export function Header() {
|
|||||||
placeholder={_(msg`Search`)}
|
placeholder={_(msg`Search`)}
|
||||||
{...searchForm.getInputProps("search")}
|
{...searchForm.getInputProps("search")}
|
||||||
leftSection={<TbSearch size={iconSize} />}
|
leftSection={<TbSearch size={iconSize} />}
|
||||||
rightSection={<CloseButton onClick={async () => await (searchFromStore && dispatch(search("")))} />}
|
rightSection={
|
||||||
|
<CloseButton
|
||||||
|
onClick={async () => await (searchFromStore && dispatch(search("")))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -31,7 +31,13 @@ import {
|
|||||||
} from "react-icons/tb"
|
} from "react-icons/tb"
|
||||||
import { throttle } from "throttle-debounce"
|
import { throttle } from "throttle-debounce"
|
||||||
import { client } from "@/app/client"
|
import { client } from "@/app/client"
|
||||||
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "@/app/redirect/thunks"
|
import {
|
||||||
|
redirectToAbout,
|
||||||
|
redirectToAdminUsers,
|
||||||
|
redirectToDonate,
|
||||||
|
redirectToMetrics,
|
||||||
|
redirectToSettings,
|
||||||
|
} from "@/app/redirect/thunks"
|
||||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import type { ViewMode } from "@/app/types"
|
import type { ViewMode } from "@/app/types"
|
||||||
import { setFontSizePercentage, setViewMode } from "@/app/user/slice"
|
import { setFontSizePercentage, setViewMode } from "@/app/user/slice"
|
||||||
@@ -124,7 +130,13 @@ export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu position="bottom-end" closeOnItemClick={false} opened={opened} onChange={setOpened} preventPositionChangeWhenVisible={false}>
|
<Menu
|
||||||
|
position="bottom-end"
|
||||||
|
closeOnItemClick={false}
|
||||||
|
opened={opened}
|
||||||
|
onChange={setOpened}
|
||||||
|
preventPositionChangeWhenVisible={false}
|
||||||
|
>
|
||||||
<Menu.Target>{props.control}</Menu.Target>
|
<Menu.Target>{props.control}</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
{profile && <Menu.Label>{profile.name}</Menu.Label>}
|
{profile && <Menu.Label>{profile.name}</Menu.Label>}
|
||||||
@@ -164,7 +176,9 @@ export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trans>Fetch all my feeds now</Trans>
|
<Trans>Fetch all my feeds now</Trans>
|
||||||
{!forceRefreshEnabled && <span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>}
|
{!forceRefreshEnabled && (
|
||||||
|
<span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>
|
||||||
|
)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|||||||
@@ -83,7 +83,11 @@ export function CustomCodeSettings() {
|
|||||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveCustomCode.loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
leftSection={<TbDeviceFloppy size={16} />}
|
||||||
|
loading={saveCustomCode.loading}
|
||||||
|
>
|
||||||
<Trans>Save</Trans>
|
<Trans>Save</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -111,7 +111,11 @@ export function DisplaySettings() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
label={<Trans>Navigate to the next category/feed with unread entries when marking all entries as read</Trans>}
|
label={
|
||||||
|
<Trans>
|
||||||
|
Navigate to the next category/feed with unread entries when marking all entries as read
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
checked={settings.markAllAsReadNavigateToNextUnread}
|
checked={settings.markAllAsReadNavigateToNextUnread}
|
||||||
onChange={e => dispatch(changeSettings({ markAllAsReadNavigateToNextUnread: e.currentTarget.checked }))}
|
onChange={e => dispatch(changeSettings({ markAllAsReadNavigateToNextUnread: e.currentTarget.checked }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Anchor, Box, Button, Checkbox, Divider, Group, Input, PasswordInput, Stack, Text, TextInput } from "@mantine/core"
|
import {
|
||||||
|
Anchor,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Input,
|
||||||
|
PasswordInput,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { openConfirmModal } from "@mantine/modals"
|
import { openConfirmModal } from "@mantine/modals"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
@@ -91,8 +103,9 @@ export function ProfileSettings() {
|
|||||||
label={<Trans>API key</Trans>}
|
label={<Trans>API key</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
This is your API key. It can be used for some read-only API operations and grants access to the Fever API
|
This is your API key. It can be used for some read-only API operations and grants access
|
||||||
and the Google Reader API. Use the form at the bottom of the page to generate a new API key
|
to the Fever API and the Google Reader API. Use the form at the bottom of the page to
|
||||||
|
generate a new API key
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
readOnly
|
readOnly
|
||||||
@@ -103,7 +116,8 @@ export function ProfileSettings() {
|
|||||||
label={<Trans>OPML export</Trans>}
|
label={<Trans>OPML export</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
Export your subscriptions and categories as an OPML file that can be imported in other feed reading services
|
Export your subscriptions and categories as an OPML file that can be imported in other
|
||||||
|
feed reading services
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -118,8 +132,8 @@ export function ProfileSettings() {
|
|||||||
label={<Trans>Fever API</Trans>}
|
label={<Trans>Fever API</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client.
|
CommaFeed is compatible with the Fever API. Use the following URL in your
|
||||||
Login with your username and your <u>API key</u>.
|
Fever-compatible mobile client. Login with your username and your <u>API key</u>.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -134,8 +148,8 @@ export function ProfileSettings() {
|
|||||||
label={<Trans>Google Reader API</Trans>}
|
label={<Trans>Google Reader API</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible
|
CommaFeed is compatible with the Google Reader API. Use the following URL in your Google
|
||||||
mobile client. Login with your username and your <u>API key</u>.
|
Reader-compatible mobile client. Login with your username and your <u>API key</u>.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -165,8 +179,14 @@ export function ProfileSettings() {
|
|||||||
description={<Trans>Changing password will generate a new API key</Trans>}
|
description={<Trans>Changing password will generate a new API key</Trans>}
|
||||||
{...form.getInputProps("newPassword")}
|
{...form.getInputProps("newPassword")}
|
||||||
/>
|
/>
|
||||||
<PasswordInput label={<Trans>Confirm password</Trans>} {...form.getInputProps("newPasswordConfirmation")} />
|
<PasswordInput
|
||||||
<Checkbox label={<Trans>Generate new API key</Trans>} {...form.getInputProps("newApiKey", { type: "checkbox" })} />
|
label={<Trans>Confirm password</Trans>}
|
||||||
|
{...form.getInputProps("newPasswordConfirmation")}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label={<Trans>Generate new API key</Trans>}
|
||||||
|
{...form.getInputProps("newApiKey", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export function PushNotificationSettings() {
|
|||||||
label={<Trans>Push notification service</Trans>}
|
label={<Trans>Push notification service</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
Receive push notifications when new feed entries are discovered. Enable "Receive push notifications" in the
|
Receive push notifications when new feed entries are discovered. Enable "Receive push
|
||||||
settings of each feed for which you want to receive notifications.
|
notifications" in the settings of each feed for which you want to receive notifications.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
data={[
|
data={[
|
||||||
@@ -77,7 +77,12 @@ export function PushNotificationSettings() {
|
|||||||
required
|
required
|
||||||
{...form.getInputProps("serverUrl")}
|
{...form.getInputProps("serverUrl")}
|
||||||
/>
|
/>
|
||||||
<TextInput label={<Trans>Topic</Trans>} placeholder="commafeed" required {...form.getInputProps("topic")} />
|
<TextInput
|
||||||
|
label={<Trans>Topic</Trans>}
|
||||||
|
placeholder="commafeed"
|
||||||
|
required
|
||||||
|
{...form.getInputProps("topic")}
|
||||||
|
/>
|
||||||
<TextInput label={<Trans>Access token</Trans>} {...form.getInputProps("userSecret")} />
|
<TextInput label={<Trans>Access token</Trans>} {...form.getInputProps("userSecret")} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -119,8 +119,11 @@ export function Tree() {
|
|||||||
const categoryNode = (category: Category, level = 0) => {
|
const categoryNode = (category: Category, level = 0) => {
|
||||||
if (!isCategoryDisplayed(category)) return null
|
if (!isCategoryDisplayed(category)) return null
|
||||||
|
|
||||||
const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
|
const hasError =
|
||||||
const hasWarning = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => !!f.filterLegacy))
|
!category.expanded &&
|
||||||
|
flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
|
||||||
|
const hasWarning =
|
||||||
|
!category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => !!f.filterLegacy))
|
||||||
return (
|
return (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
id={category.id}
|
id={category.id}
|
||||||
|
|||||||
@@ -80,7 +80,11 @@ export function TreeNode(props: Readonly<TreeNodeProps>) {
|
|||||||
data-type={props.type}
|
data-type={props.type}
|
||||||
data-unread-count={props.unread}
|
data-unread-count={props.unread}
|
||||||
>
|
>
|
||||||
<Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)} className="cf-treenode-icon">
|
<Box
|
||||||
|
mr={6}
|
||||||
|
onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}
|
||||||
|
className="cf-treenode-icon"
|
||||||
|
>
|
||||||
<Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center>
|
<Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className={classes.nodeText}>{props.name}</Box>
|
<Box className={classes.nodeText}>{props.name}</Box>
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { Anchor, Box, Center, Container, Divider, Group, Image, Space, Title, useMantineColorScheme } from "@mantine/core"
|
import {
|
||||||
|
Anchor,
|
||||||
|
Box,
|
||||||
|
Center,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Image,
|
||||||
|
Space,
|
||||||
|
Title,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { SiGithub, SiX } from "react-icons/si"
|
import { SiGithub, SiX } from "react-icons/si"
|
||||||
import { TbClock, TbKey, TbMoon, TbSettings, TbSun, TbUserPlus } from "react-icons/tb"
|
import { TbClock, TbKey, TbMoon, TbSettings, TbSun, TbUserPlus } from "react-icons/tb"
|
||||||
import { client } from "@/app/client"
|
import { client } from "@/app/client"
|
||||||
import { redirectToApiDocumentation, redirectToLogin, redirectToRegistration, redirectToRootCategory } from "@/app/redirect/thunks"
|
import {
|
||||||
|
redirectToApiDocumentation,
|
||||||
|
redirectToLogin,
|
||||||
|
redirectToRegistration,
|
||||||
|
redirectToRootCategory,
|
||||||
|
} from "@/app/redirect/thunks"
|
||||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import welcomePageDark from "@/assets/welcome_page_dark.png"
|
import welcomePageDark from "@/assets/welcome_page_dark.png"
|
||||||
import welcomePageLight from "@/assets/welcome_page_light.png"
|
import welcomePageLight from "@/assets/welcome_page_light.png"
|
||||||
|
|||||||
@@ -69,7 +69,11 @@ export function AdminUsersPage() {
|
|||||||
<Title order={3} mb="md">
|
<Title order={3} mb="md">
|
||||||
<Group>
|
<Group>
|
||||||
<Trans>Manage users</Trans>
|
<Trans>Manage users</Trans>
|
||||||
<ActionIcon color={theme.primaryColor} variant="subtle" onClick={() => openUserEditModal(<Trans>Add user</Trans>)}>
|
<ActionIcon
|
||||||
|
color={theme.primaryColor}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => openUserEditModal(<Trans>Add user</Trans>)}
|
||||||
|
>
|
||||||
<TbPlus size={20} />
|
<TbPlus size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ const shownGauges: Record<string, string> = {
|
|||||||
"com.commafeed.backend.feed.FeedRefreshEngine.queue.size": "Feed Refresh Engine queue size",
|
"com.commafeed.backend.feed.FeedRefreshEngine.queue.size": "Feed Refresh Engine queue size",
|
||||||
"com.commafeed.backend.feed.FeedRefreshEngine.worker.active": "Feed Refresh Engine active HTTP workers",
|
"com.commafeed.backend.feed.FeedRefreshEngine.worker.active": "Feed Refresh Engine active HTTP workers",
|
||||||
"com.commafeed.backend.feed.FeedRefreshEngine.updater.active": "Feed Refresh Engine active database update workers",
|
"com.commafeed.backend.feed.FeedRefreshEngine.updater.active": "Feed Refresh Engine active database update workers",
|
||||||
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.active": "Feed Refresh Engine active push notifications workers",
|
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.active":
|
||||||
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.queue": "Feed Refresh Engine queued push notifications workers",
|
"Feed Refresh Engine active push notifications workers",
|
||||||
|
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.queue":
|
||||||
|
"Feed Refresh Engine queued push notifications workers",
|
||||||
"com.commafeed.backend.HttpGetter.cache.size": "HttpGetter cached entries",
|
"com.commafeed.backend.HttpGetter.cache.size": "HttpGetter cached entries",
|
||||||
"com.commafeed.backend.HttpGetter.cache.memoryUsage": "HttpGetter cache memory usage",
|
"com.commafeed.backend.HttpGetter.cache.memoryUsage": "HttpGetter cache memory usage",
|
||||||
"com.commafeed.frontend.ws.WebSocketSessions.users": "WebSocket users",
|
"com.commafeed.frontend.ws.WebSocketSessions.users": "WebSocket users",
|
||||||
|
|||||||
@@ -51,7 +51,12 @@ function NextUnreadBookmarklet() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<CategorySelect value={categoryId} onChange={c => c && setCategoryId(c)} withAll description={<Trans>Category</Trans>} />
|
<CategorySelect
|
||||||
|
value={categoryId}
|
||||||
|
onChange={c => c && setCategoryId(c)}
|
||||||
|
withAll
|
||||||
|
description={<Trans>Category</Trans>}
|
||||||
|
/>
|
||||||
<NativeSelect
|
<NativeSelect
|
||||||
data={[
|
data={[
|
||||||
{ value: "desc", label: _(msg`Newest first`) },
|
{ value: "desc", label: _(msg`Newest first`) },
|
||||||
@@ -73,7 +78,8 @@ function NextUnreadBookmarklet() {
|
|||||||
export function AboutPage() {
|
export function AboutPage() {
|
||||||
const version = useAppSelector(state => state.server.serverInfos?.version)
|
const version = useAppSelector(state => state.server.serverInfos?.version)
|
||||||
const revision = useAppSelector(state => state.server.serverInfos?.gitCommit)
|
const revision = useAppSelector(state => state.server.serverInfos?.gitCommit)
|
||||||
const { isBrowserExtensionInstalled, browserExtensionVersion, isBrowserExtensionInstallable } = useBrowserExtension()
|
const { isBrowserExtensionInstalled, browserExtensionVersion, isBrowserExtensionInstallable } =
|
||||||
|
useBrowserExtension()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,7 +106,9 @@ export function AboutPage() {
|
|||||||
</Trans>
|
</Trans>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Trans>If you encounter an issue, please report it on the issues page of the GitHub project.</Trans>
|
<Trans>
|
||||||
|
If you encounter an issue, please report it on the issues page of the GitHub project.
|
||||||
|
</Trans>
|
||||||
</Box>
|
</Box>
|
||||||
</Section>
|
</Section>
|
||||||
<Section title={<Trans>Goodies</Trans>} icon={<TbPuzzle size={24} />}>
|
<Section title={<Trans>Goodies</Trans>} icon={<TbPuzzle size={24} />}>
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Anchor, Box, Button, Code, Container, Divider, Group, Input, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core"
|
import {
|
||||||
|
Anchor,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Code,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Input,
|
||||||
|
NumberInput,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { openConfirmModal } from "@mantine/modals"
|
import { openConfirmModal } from "@mantine/modals"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
@@ -130,7 +144,12 @@ export function CategoryDetailsPage() {
|
|||||||
clearable
|
clearable
|
||||||
withoutCategoryIds={excludedCategoryIds}
|
withoutCategoryIds={excludedCategoryIds}
|
||||||
/>
|
/>
|
||||||
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
|
<NumberInput
|
||||||
|
label={<Trans>Position</Trans>}
|
||||||
|
{...form.getInputProps("position")}
|
||||||
|
required
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -140,7 +159,11 @@ export function CategoryDetailsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
{editable && (
|
{editable && (
|
||||||
<>
|
<>
|
||||||
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={modifyCategory.loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
leftSection={<TbDeviceFloppy size={16} />}
|
||||||
|
loading={modifyCategory.loading}
|
||||||
|
>
|
||||||
<Trans>Save</Trans>
|
<Trans>Save</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Divider orientation="vertical" />
|
<Divider orientation="vertical" />
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export function DonatePage() {
|
|||||||
<Trans>
|
<Trans>
|
||||||
<Box>Hey,</Box>
|
<Box>Hey,</Box>
|
||||||
<Box mt="xs">
|
<Box mt="xs">
|
||||||
I'm Jérémie from Belgium and I've been working on CommaFeed in my free time for over 10 years now. Thanks for taking
|
I'm Jérémie from Belgium and I've been working on CommaFeed in my free time for over 10 years
|
||||||
an interest in helping me continue supporting CommaFeed.
|
now. Thanks for taking an interest in helping me continue supporting CommaFeed.
|
||||||
</Box>
|
</Box>
|
||||||
</Trans>
|
</Trans>
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,11 @@ export function FeedDetailsPage() {
|
|||||||
<Input.Wrapper label={<Trans>Generated feed url</Trans>}>
|
<Input.Wrapper label={<Trans>Generated feed url</Trans>}>
|
||||||
<Box>
|
<Box>
|
||||||
{apiKey && (
|
{apiKey && (
|
||||||
<Anchor href={`rest/feed/entriesAsFeed?id=${feed.id}&apiKey=${apiKey}`} target="_blank" rel="noreferrer">
|
<Anchor
|
||||||
|
href={`rest/feed/entriesAsFeed?id=${feed.id}&apiKey=${apiKey}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
<Trans>Link</Trans>
|
<Trans>Link</Trans>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
@@ -146,10 +150,16 @@ export function FeedDetailsPage() {
|
|||||||
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
||||||
<CategorySelect label={<Trans>Category</Trans>} {...form.getInputProps("categoryId")} clearable />
|
<CategorySelect label={<Trans>Category</Trans>} {...form.getInputProps("categoryId")} clearable />
|
||||||
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
|
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
|
||||||
<ReceivePushNotificationsChechbox {...form.getInputProps("pushNotificationsEnabled", { type: "checkbox" })} />
|
<ReceivePushNotificationsChechbox
|
||||||
|
{...form.getInputProps("pushNotificationsEnabled", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={<Trans>Auto-mark as read</Trans>}
|
label={<Trans>Auto-mark as read</Trans>}
|
||||||
description={<Trans>Mark entries in this feed as read after this number of days. Leave empty to disable.</Trans>}
|
description={
|
||||||
|
<Trans>
|
||||||
|
Mark entries in this feed as read after this number of days. Leave empty to disable.
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
suffix={` ${_(msg`days`)}`}
|
suffix={` ${_(msg`days`)}`}
|
||||||
{...form.getInputProps("autoMarkAsReadAfterDays")}
|
{...form.getInputProps("autoMarkAsReadAfterDays")}
|
||||||
min={1}
|
min={1}
|
||||||
@@ -159,21 +169,25 @@ export function FeedDetailsPage() {
|
|||||||
label={<Trans>Filtering expression</Trans>}
|
label={<Trans>Filtering expression</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
Build a filter expression to indicate what you want to read. Entries that don't match will be marked as read
|
Build a filter expression to indicate what you want to read. Entries that don't match
|
||||||
automatically.
|
will be marked as read automatically.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{feed.filterLegacy && (
|
{feed.filterLegacy && (
|
||||||
<MantineAlert color="yellow" icon={<TbAlertTriangle />}>
|
<MantineAlert color="yellow" icon={<TbAlertTriangle />}>
|
||||||
<Trans>
|
<Trans>
|
||||||
This feed has a legacy filter that cannot be edited and is not applied. Please recreate the filter using
|
This feed has a legacy filter that cannot be edited and is not applied. Please
|
||||||
the new expression editor. The legacy filter expression was: <Code>{feed.filterLegacy}</Code>
|
recreate the filter using the new expression editor. The legacy filter expression
|
||||||
|
was: <Code>{feed.filterLegacy}</Code>
|
||||||
</Trans>
|
</Trans>
|
||||||
</MantineAlert>
|
</MantineAlert>
|
||||||
)}
|
)}
|
||||||
<Box mt="xs">
|
<Box mt="xs">
|
||||||
<FilteringExpressionEditor initialValue={feed.filter} onChange={value => form.setFieldValue("filter", value)} />
|
<FilteringExpressionEditor
|
||||||
|
initialValue={feed.filter}
|
||||||
|
onChange={value => form.setFieldValue("filter", value)}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Input.Wrapper>
|
</Input.Wrapper>
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ function NoSubscriptionHelp() {
|
|||||||
<Box>
|
<Box>
|
||||||
<Center>
|
<Center>
|
||||||
<Trans>
|
<Trans>
|
||||||
You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of the page?
|
You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of
|
||||||
|
the page?
|
||||||
</Trans>
|
</Trans>
|
||||||
</Center>
|
</Center>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -51,7 +52,8 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
|
|||||||
const viewport = useViewportSize()
|
const viewport = useViewportSize()
|
||||||
const theme = useMantineTheme()
|
const theme = useMantineTheme()
|
||||||
const noSubscriptions = useAppSelector(
|
const noSubscriptions = useAppSelector(
|
||||||
state => state.tree.rootCategory && flattenCategoryTree(state.tree.rootCategory).every(c => c.feeds.length === 0)
|
state =>
|
||||||
|
state.tree.rootCategory && flattenCategoryTree(state.tree.rootCategory).every(c => c.feeds.length === 0)
|
||||||
)
|
)
|
||||||
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
|
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
|
||||||
const sourceWebsiteUrl = useAppSelector(state => state.entries.sourceWebsiteUrl)
|
const sourceWebsiteUrl = useAppSelector(state => state.entries.sourceWebsiteUrl)
|
||||||
@@ -63,7 +65,8 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
|
|||||||
if (!root) return { unreadCount: 0, hasNewEntries: false }
|
if (!root) return { unreadCount: 0, hasNewEntries: false }
|
||||||
|
|
||||||
if (props.sourceType === "category") {
|
if (props.sourceType === "category") {
|
||||||
const category = id === Constants.categories.all.id ? root : flattenCategoryTree(root).find(c => c.id === id)
|
const category =
|
||||||
|
id === Constants.categories.all.id ? root : flattenCategoryTree(root).find(c => c.id === id)
|
||||||
return {
|
return {
|
||||||
unreadCount: categoryUnreadCount(category),
|
unreadCount: categoryUnreadCount(category),
|
||||||
hasNewEntries: categoryHasNewEntries(category),
|
hasNewEntries: categoryHasNewEntries(category),
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export function InitialSetupPage() {
|
|||||||
</Title>
|
</Title>
|
||||||
<Box mb="md">
|
<Box mb="md">
|
||||||
<Trans>
|
<Trans>
|
||||||
Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get
|
Welcome! This appears to be the first time you're running CommaFeed. Please create an
|
||||||
started.
|
administrator account to get started.
|
||||||
</Trans>
|
</Trans>
|
||||||
</Box>
|
</Box>
|
||||||
{setup.error && (
|
{setup.error && (
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core"
|
import {
|
||||||
|
Anchor,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
PasswordInput,
|
||||||
|
Stack,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
@@ -49,7 +61,11 @@ export function LoginPage() {
|
|||||||
placeholder={_(msg`User Name or E-mail`)}
|
placeholder={_(msg`User Name or E-mail`)}
|
||||||
{...form.getInputProps("name")}
|
{...form.getInputProps("name")}
|
||||||
description={
|
description={
|
||||||
serverInfos?.demoAccountEnabled ? <Trans>Try out CommaFeed with the demo account: demo/demo</Trans> : ""
|
serverInfos?.demoAccountEnabled ? (
|
||||||
|
<Trans>Try out CommaFeed with the demo account: demo/demo</Trans>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
size="md"
|
size="md"
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core"
|
import {
|
||||||
|
Anchor,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
PasswordInput,
|
||||||
|
Stack,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
@@ -71,7 +83,13 @@ export function RegistrationPage() {
|
|||||||
|
|
||||||
<form onSubmit={form.onSubmit(register.execute)}>
|
<form onSubmit={form.onSubmit(register.execute)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label="User Name" placeholder="User Name" {...form.getInputProps("name")} size="md" required />
|
<TextInput
|
||||||
|
label="User Name"
|
||||||
|
placeholder="User Name"
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
size="md"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
type="email"
|
type="email"
|
||||||
label={<Trans>E-mail address</Trans>}
|
label={<Trans>E-mail address</Trans>}
|
||||||
|
|||||||
@@ -274,7 +274,7 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>com.diffplug.spotless</groupId>
|
<groupId>com.diffplug.spotless</groupId>
|
||||||
<artifactId>spotless-maven-plugin</artifactId>
|
<artifactId>spotless-maven-plugin</artifactId>
|
||||||
<version>3.10.4</version>
|
<version>3.11.0</version>
|
||||||
<?m2e ignore?>
|
<?m2e ignore?>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
@@ -290,6 +290,7 @@
|
|||||||
<googleJavaFormat>
|
<googleJavaFormat>
|
||||||
<version>${google-java-format.version}</version>
|
<version>${google-java-format.version}</version>
|
||||||
<style>AOSP</style>
|
<style>AOSP</style>
|
||||||
|
<maxLineLength>120</maxLineLength>
|
||||||
<reorderImports>true</reorderImports>
|
<reorderImports>true</reorderImports>
|
||||||
</googleJavaFormat>
|
</googleJavaFormat>
|
||||||
</java>
|
</java>
|
||||||
@@ -434,7 +435,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.jsoup</groupId>
|
<groupId>org.jsoup</groupId>
|
||||||
<artifactId>jsoup</artifactId>
|
<artifactId>jsoup</artifactId>
|
||||||
<version>1.23.2</version>
|
<version>1.24.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.ibm.icu</groupId>
|
<groupId>com.ibm.icu</groupId>
|
||||||
|
|||||||
@@ -34,16 +34,14 @@ public class CommaFeedApplication {
|
|||||||
System.setProperty(SystemProperties.JDK_XML_MAX_GENERAL_ENTITY_SIZE_LIMIT, "0");
|
System.setProperty(SystemProperties.JDK_XML_MAX_GENERAL_ENTITY_SIZE_LIMIT, "0");
|
||||||
System.setProperty(SystemProperties.JDK_XML_TOTAL_ENTITY_SIZE_LIMIT, "0");
|
System.setProperty(SystemProperties.JDK_XML_TOTAL_ENTITY_SIZE_LIMIT, "0");
|
||||||
|
|
||||||
PasswordConstraintValidator.setMinimumPasswordLength(
|
PasswordConstraintValidator.setMinimumPasswordLength(config.users().minimumPasswordLength());
|
||||||
config.users().minimumPasswordLength());
|
|
||||||
|
|
||||||
if (config.imageProxyEnabled()) {
|
if (config.imageProxyEnabled()) {
|
||||||
ImageProxyUrl.generateKey();
|
ImageProxyUrl.generateKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.passwordRecoveryEnabled() && config.passwordRecoveryPublicBaseUrl().isEmpty()) {
|
if (config.passwordRecoveryEnabled() && config.passwordRecoveryPublicBaseUrl().isEmpty()) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException("password recovery is enabled but no public base url is set");
|
||||||
"password recovery is enabled but no public base url is set");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
feedRefreshEngine.start();
|
feedRefreshEngine.start();
|
||||||
|
|||||||
@@ -19,24 +19,20 @@ import java.util.Optional;
|
|||||||
/**
|
/**
|
||||||
* CommaFeed configuration
|
* CommaFeed configuration
|
||||||
*
|
*
|
||||||
* <p>Default values are for production, they can be overridden in application.properties for other
|
* <p>Default values are for production, they can be overridden in application.properties for other profiles
|
||||||
* profiles
|
|
||||||
*/
|
*/
|
||||||
@ConfigMapping(prefix = "commafeed")
|
@ConfigMapping(prefix = "commafeed")
|
||||||
@ConfigRoot(phase = ConfigPhase.RUN_TIME)
|
@ConfigRoot(phase = ConfigPhase.RUN_TIME)
|
||||||
public interface CommaFeedConfiguration {
|
public interface CommaFeedConfiguration {
|
||||||
/**
|
/** Whether to expose a robots.txt file that disallows web crawlers and search engine indexers. */
|
||||||
* Whether to expose a robots.txt file that disallows web crawlers and search engine indexers.
|
|
||||||
*/
|
|
||||||
@WithDefault("true")
|
@WithDefault("true")
|
||||||
boolean hideFromWebCrawlers();
|
boolean hideFromWebCrawlers();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If enabled, images in feed entries will be proxied through the server instead of accessed
|
* If enabled, images in feed entries will be proxied through the server instead of accessed directly by the
|
||||||
* directly by the browser.
|
* browser.
|
||||||
*
|
*
|
||||||
* <p>This is useful if commafeed is accessed through a restricting proxy that blocks some feeds
|
* <p>This is useful if commafeed is accessed through a restricting proxy that blocks some feeds that are followed.
|
||||||
* that are followed.
|
|
||||||
*/
|
*/
|
||||||
@WithDefault("false")
|
@WithDefault("false")
|
||||||
boolean imageProxyEnabled();
|
boolean imageProxyEnabled();
|
||||||
@@ -50,9 +46,8 @@ public interface CommaFeedConfiguration {
|
|||||||
boolean passwordRecoveryEnabled();
|
boolean passwordRecoveryEnabled();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The base URL of the application to use in the password recovery link in the email. We can't
|
* The base URL of the application to use in the password recovery link in the email. We can't use the URL sent by
|
||||||
* use the URL sent by the browser of the user because of malicious Host header injection
|
* the browser of the user because of malicious Host header injection attacks.
|
||||||
* attacks.
|
|
||||||
*/
|
*/
|
||||||
Optional<String> passwordRecoveryPublicBaseUrl();
|
Optional<String> passwordRecoveryPublicBaseUrl();
|
||||||
|
|
||||||
@@ -87,16 +82,14 @@ public interface CommaFeedConfiguration {
|
|||||||
Websocket websocket();
|
Websocket websocket();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duration to wait for the feed refresh engine and the task scheduler to stop when the
|
* Duration to wait for the feed refresh engine and the task scheduler to stop when the application is shutting
|
||||||
* application is shutting down.
|
* down.
|
||||||
*/
|
*/
|
||||||
@WithDefault("2s")
|
@WithDefault("2s")
|
||||||
Duration shutdownTimeout();
|
Duration shutdownTimeout();
|
||||||
|
|
||||||
interface HttpClient {
|
interface HttpClient {
|
||||||
/**
|
/** User-Agent string that will be used by the http client, leave empty for the default one. */
|
||||||
* User-Agent string that will be used by the http client, leave empty for the default one.
|
|
||||||
*/
|
|
||||||
Optional<String> userAgent();
|
Optional<String> userAgent();
|
||||||
|
|
||||||
/** Time to wait for a connection to be established. */
|
/** Time to wait for a connection to be established. */
|
||||||
@@ -123,19 +116,15 @@ public interface CommaFeedConfiguration {
|
|||||||
@WithDefault("1m")
|
@WithDefault("1m")
|
||||||
Duration idleConnectionsEvictionInterval();
|
Duration idleConnectionsEvictionInterval();
|
||||||
|
|
||||||
/**
|
/** If a feed is larger than this, it will be discarded to prevent memory issues while parsing the feed. */
|
||||||
* If a feed is larger than this, it will be discarded to prevent memory issues while
|
|
||||||
* parsing the feed.
|
|
||||||
*/
|
|
||||||
@WithDefault("5M")
|
@WithDefault("5M")
|
||||||
MemorySize maxResponseSize();
|
MemorySize maxResponseSize();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks,
|
* Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks, which could
|
||||||
* which could potentially expose internal resources.
|
* potentially expose internal resources.
|
||||||
*
|
*
|
||||||
* <p>You may want to enable this if you host a public instance of CommaFeed with
|
* <p>You may want to enable this if you host a public instance of CommaFeed with registrations open.
|
||||||
* registrations open.
|
|
||||||
*/
|
*/
|
||||||
@WithDefault("true")
|
@WithDefault("true")
|
||||||
boolean blockLocalAddresses();
|
boolean blockLocalAddresses();
|
||||||
@@ -147,9 +136,8 @@ public interface CommaFeedConfiguration {
|
|||||||
|
|
||||||
interface HttpClientCache {
|
interface HttpClientCache {
|
||||||
/**
|
/**
|
||||||
* Whether to enable the cache. This cache is used to avoid spamming feeds in short bursts
|
* Whether to enable the cache. This cache is used to avoid spamming feeds in short bursts (e.g. when
|
||||||
* (e.g. when subscribing to a feed for the first time or when clicking "fetch all my feeds
|
* subscribing to a feed for the first time or when clicking "fetch all my feeds now").
|
||||||
* now").
|
|
||||||
*/
|
*/
|
||||||
@WithDefault("true")
|
@WithDefault("true")
|
||||||
boolean enabled();
|
boolean enabled();
|
||||||
@@ -169,8 +157,7 @@ public interface CommaFeedConfiguration {
|
|||||||
Duration interval();
|
Duration interval();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum amount of time CommaFeed will wait before refreshing a feed. This is used as an
|
* Maximum amount of time CommaFeed will wait before refreshing a feed. This is used as an upper bound when:
|
||||||
* upper bound when:
|
|
||||||
*
|
*
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>an error occurs while refreshing a feed and we're backing off exponentially
|
* <li>an error occurs while refreshing a feed and we're backing off exponentially
|
||||||
@@ -182,10 +169,10 @@ public interface CommaFeedConfiguration {
|
|||||||
Duration maxInterval();
|
Duration maxInterval();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If enabled, CommaFeed will calculate the next refresh time based on the feed's average
|
* If enabled, CommaFeed will calculate the next refresh time based on the feed's average time between entries
|
||||||
* time between entries and the time since the last entry was published. The interval will
|
* and the time since the last entry was published. The interval will be sometimes between the default refresh
|
||||||
* be sometimes between the default refresh interval (`commafeed.feed-refresh.interval`) and
|
* interval (`commafeed.feed-refresh.interval`) and the maximum refresh interval
|
||||||
* the maximum refresh interval (`commafeed.feed-refresh.max-interval`).
|
* (`commafeed.feed-refresh.max-interval`).
|
||||||
*
|
*
|
||||||
* <p>See {@link FeedRefreshIntervalCalculator} for details.
|
* <p>See {@link FeedRefreshIntervalCalculator} for details.
|
||||||
*/
|
*/
|
||||||
@@ -207,8 +194,8 @@ public interface CommaFeedConfiguration {
|
|||||||
int databaseThreads();
|
int databaseThreads();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duration after which a user is considered inactive. Feeds for inactive users are not
|
* Duration after which a user is considered inactive. Feeds for inactive users are not refreshed until they log
|
||||||
* refreshed until they log in again.
|
* in again.
|
||||||
*
|
*
|
||||||
* <p>0 to disable.
|
* <p>0 to disable.
|
||||||
*/
|
*/
|
||||||
@@ -216,15 +203,15 @@ public interface CommaFeedConfiguration {
|
|||||||
Duration userInactivityPeriod();
|
Duration userInactivityPeriod();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duration after which the evaluation of a filtering expresion to mark an entry as read is
|
* Duration after which the evaluation of a filtering expresion to mark an entry as read is considered to have
|
||||||
* considered to have timed out.
|
* timed out.
|
||||||
*/
|
*/
|
||||||
@WithDefault("500ms")
|
@WithDefault("500ms")
|
||||||
Duration filteringExpressionEvaluationTimeout();
|
Duration filteringExpressionEvaluationTimeout();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duration after which the "Fetch all my feeds now" action is available again after use to
|
* Duration after which the "Fetch all my feeds now" action is available again after use to avoid spamming
|
||||||
* avoid spamming feeds.
|
* feeds.
|
||||||
*/
|
*/
|
||||||
@WithDefault("0")
|
@WithDefault("0")
|
||||||
Duration forceRefreshCooldownDuration();
|
Duration forceRefreshCooldownDuration();
|
||||||
@@ -240,10 +227,7 @@ public interface CommaFeedConfiguration {
|
|||||||
@WithDefault("5")
|
@WithDefault("5")
|
||||||
int threads();
|
int threads();
|
||||||
|
|
||||||
/**
|
/** Maximum amount of notifications that can be queued before new notifications are discarded. */
|
||||||
* Maximum amount of notifications that can be queued before new notifications are
|
|
||||||
* discarded.
|
|
||||||
*/
|
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@WithDefault("100")
|
@WithDefault("100")
|
||||||
int queueCapacity();
|
int queueCapacity();
|
||||||
@@ -256,8 +240,8 @@ public interface CommaFeedConfiguration {
|
|||||||
int retriesBeforeBackoff();
|
int retriesBeforeBackoff();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duration to wait before retrying after an error. Will be multiplied by the number of
|
* Duration to wait before retrying after an error. Will be multiplied by the number of errors since the last
|
||||||
* errors since the last successful fetch.
|
* successful fetch.
|
||||||
*/
|
*/
|
||||||
@WithDefault("1h")
|
@WithDefault("1h")
|
||||||
Duration backoffInterval();
|
Duration backoffInterval();
|
||||||
@@ -286,8 +270,7 @@ public interface CommaFeedConfiguration {
|
|||||||
Duration entriesMaxAge();
|
Duration entriesMaxAge();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum age of feed entry statuses (read/unread) in the database. Older statuses will
|
* Maximum age of feed entry statuses (read/unread) in the database. Older statuses will be deleted.
|
||||||
* be deleted.
|
|
||||||
*
|
*
|
||||||
* <p>0 to disable.
|
* <p>0 to disable.
|
||||||
*/
|
*/
|
||||||
@@ -320,9 +303,7 @@ public interface CommaFeedConfiguration {
|
|||||||
boolean keepStarredEntries();
|
boolean keepStarredEntries();
|
||||||
|
|
||||||
default Instant statusesInstantThreshold() {
|
default Instant statusesInstantThreshold() {
|
||||||
return statusesMaxAge().toMillis() > 0
|
return statusesMaxAge().toMillis() > 0 ? Instant.now().minus(statusesMaxAge()) : null;
|
||||||
? Instant.now().minus(statusesMaxAge())
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,23 +327,17 @@ public interface CommaFeedConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Websocket {
|
interface Websocket {
|
||||||
/**
|
/** Enable websocket connection so the server can notify web clients that there are new entries for feeds. */
|
||||||
* Enable websocket connection so the server can notify web clients that there are new
|
|
||||||
* entries for feeds.
|
|
||||||
*/
|
|
||||||
@WithDefault("true")
|
@WithDefault("true")
|
||||||
boolean enabled();
|
boolean enabled();
|
||||||
|
|
||||||
/**
|
/** Interval at which the client will send a ping message on the websocket to keep the connection alive. */
|
||||||
* Interval at which the client will send a ping message on the websocket to keep the
|
|
||||||
* connection alive.
|
|
||||||
*/
|
|
||||||
@WithDefault("15m")
|
@WithDefault("15m")
|
||||||
Duration pingInterval();
|
Duration pingInterval();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the websocket connection is disabled or the connection is lost, the client will reload
|
* If the websocket connection is disabled or the connection is lost, the client will reload the feed tree at
|
||||||
* the feed tree at this interval.
|
* this interval.
|
||||||
*/
|
*/
|
||||||
@WithDefault("30s")
|
@WithDefault("30s")
|
||||||
Duration treeReloadInterval();
|
Duration treeReloadInterval();
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import io.quarkus.security.UnauthorizedException;
|
|||||||
|
|
||||||
import jakarta.annotation.Priority;
|
import jakarta.annotation.Priority;
|
||||||
import jakarta.validation.ValidationException;
|
import jakarta.validation.ValidationException;
|
||||||
|
import jakarta.ws.rs.core.HttpHeaders;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.NewCookie;
|
import jakarta.ws.rs.core.NewCookie;
|
||||||
|
import jakarta.ws.rs.core.UriInfo;
|
||||||
import jakarta.ws.rs.ext.Provider;
|
import jakarta.ws.rs.ext.Provider;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -29,11 +32,9 @@ public class ExceptionMappers {
|
|||||||
private final CommaFeedConfiguration config;
|
private final CommaFeedConfiguration config;
|
||||||
|
|
||||||
@ServerExceptionMapper(CommaFeedApplicationException.class)
|
@ServerExceptionMapper(CommaFeedApplicationException.class)
|
||||||
public RestResponse<CommaFeedApplicationError> applicationError(
|
public RestResponse<CommaFeedApplicationError> applicationError(CommaFeedApplicationException e) {
|
||||||
CommaFeedApplicationException e) {
|
|
||||||
ResponseBuilder<CommaFeedApplicationError> response =
|
ResponseBuilder<CommaFeedApplicationError> response =
|
||||||
ResponseBuilder.create(
|
ResponseBuilder.create(e.type().status(), new CommaFeedApplicationError(e.type(), e.getMessage()));
|
||||||
e.type().status(), new CommaFeedApplicationError(e.type(), e.getMessage()));
|
|
||||||
if (e.type().status() == Status.UNAUTHORIZED) {
|
if (e.type().status() == Status.UNAUTHORIZED) {
|
||||||
response.cookie(cookieService.buildLogoutCookie());
|
response.cookie(cookieService.buildLogoutCookie());
|
||||||
}
|
}
|
||||||
@@ -43,14 +44,24 @@ public class ExceptionMappers {
|
|||||||
@ServerExceptionMapper(UnauthorizedException.class)
|
@ServerExceptionMapper(UnauthorizedException.class)
|
||||||
public RestResponse<UnauthorizedResponse> unauthorized(UnauthorizedException e) {
|
public RestResponse<UnauthorizedResponse> unauthorized(UnauthorizedException e) {
|
||||||
return RestResponse.status(
|
return RestResponse.status(
|
||||||
Status.UNAUTHORIZED,
|
Status.UNAUTHORIZED, new UnauthorizedResponse(e.getMessage(), config.users().allowRegistrations()));
|
||||||
new UnauthorizedResponse(e.getMessage(), config.users().allowRegistrations()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ServerExceptionMapper(AuthenticationFailedException.class)
|
@ServerExceptionMapper(AuthenticationFailedException.class)
|
||||||
public RestResponse<AuthenticationFailed> authenticationFailed(
|
public RestResponse<AuthenticationFailed> authenticationFailed(
|
||||||
AuthenticationFailedException e) {
|
AuthenticationFailedException e, HttpHeaders headers, UriInfo uriInfo) {
|
||||||
NewCookie logoutCookie = cookieService.buildLogoutCookie();
|
NewCookie logoutCookie = cookieService.buildLogoutCookie();
|
||||||
|
|
||||||
|
// a browser navigating with an invalidated session would otherwise display the error
|
||||||
|
// instead of the page, retry the request now that the cookie is removed
|
||||||
|
if (headers.getAcceptableMediaTypes().stream()
|
||||||
|
.anyMatch(m -> MediaType.TEXT_HTML_TYPE.isCompatible(m) && !m.isWildcardType())) {
|
||||||
|
return ResponseBuilder.<AuthenticationFailed>create(Status.TEMPORARY_REDIRECT)
|
||||||
|
.location(uriInfo.getRequestUri())
|
||||||
|
.cookie(logoutCookie)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseBuilder.create(Status.UNAUTHORIZED, new AuthenticationFailed(e.getMessage()))
|
return ResponseBuilder.create(Status.UNAUTHORIZED, new AuthenticationFailed(e.getMessage()))
|
||||||
.cookie(logoutCookie)
|
.cookie(logoutCookie)
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -56,27 +56,24 @@ public class HttpClientFactory {
|
|||||||
|
|
||||||
private static final DnsResolver DNS_RESOLVER = SystemDefaultDnsResolver.INSTANCE;
|
private static final DnsResolver DNS_RESOLVER = SystemDefaultDnsResolver.INSTANCE;
|
||||||
private static final IPAddress CGNAT_RANGE = new IPAddressString("100.64.0.0/10").getAddress();
|
private static final IPAddress CGNAT_RANGE = new IPAddressString("100.64.0.0/10").getAddress();
|
||||||
|
private static final IPAddress NAT64_LOCAL_USE_RANGE = new IPAddressString("64:ff9b:1::/48").getAddress();
|
||||||
|
|
||||||
private final CommaFeedConfiguration config;
|
private final CommaFeedConfiguration config;
|
||||||
private final CommaFeedVersion version;
|
private final CommaFeedVersion version;
|
||||||
|
|
||||||
public CloseableHttpAsyncClient newClient(int poolSize) {
|
public CloseableHttpAsyncClient newClient(int poolSize) {
|
||||||
PoolingAsyncClientConnectionManager connectionManager =
|
PoolingAsyncClientConnectionManager connectionManager = newConnectionManager(config, poolSize);
|
||||||
newConnectionManager(config, poolSize);
|
|
||||||
String userAgent =
|
String userAgent =
|
||||||
config.httpClient()
|
config.httpClient()
|
||||||
.userAgent()
|
.userAgent()
|
||||||
.orElseGet(() ->
|
.orElseGet(() ->
|
||||||
String.format(
|
String.format(
|
||||||
"CommaFeed/%s (https://github.com/Athou/commafeed)",
|
"CommaFeed/%s (https://github.com/Athou/commafeed)", version.getVersion()));
|
||||||
version.getVersion()));
|
|
||||||
return newClient(config, connectionManager, userAgent);
|
return newClient(config, connectionManager, userAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
private CloseableHttpAsyncClient newClient(
|
private CloseableHttpAsyncClient newClient(
|
||||||
CommaFeedConfiguration config,
|
CommaFeedConfiguration config, PoolingAsyncClientConnectionManager connectionManager, String userAgent) {
|
||||||
PoolingAsyncClientConnectionManager connectionManager,
|
|
||||||
String userAgent) {
|
|
||||||
List<Header> headers = new ArrayList<>();
|
List<Header> headers = new ArrayList<>();
|
||||||
headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en"));
|
headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en"));
|
||||||
headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache"));
|
headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache"));
|
||||||
@@ -84,10 +81,8 @@ public class HttpClientFactory {
|
|||||||
headers.add(new BasicHeader(HttpHeaders.ACCEPT, "*/*"));
|
headers.add(new BasicHeader(HttpHeaders.ACCEPT, "*/*"));
|
||||||
headers.add(new BasicHeader(HttpHeaders.SEC_FETCH_MODE, "navigate"));
|
headers.add(new BasicHeader(HttpHeaders.SEC_FETCH_MODE, "navigate"));
|
||||||
|
|
||||||
SequencedMap<String, UnaryOperator<AsyncDataConsumer>> contentDecoderMap =
|
SequencedMap<String, UnaryOperator<AsyncDataConsumer>> contentDecoderMap = new LinkedHashMap<>();
|
||||||
new LinkedHashMap<>();
|
contentDecoderMap.put(ContentCoding.DEFLATE.token(), d -> new InflatingAsyncDataConsumer(d, null));
|
||||||
contentDecoderMap.put(
|
|
||||||
ContentCoding.DEFLATE.token(), d -> new InflatingAsyncDataConsumer(d, null));
|
|
||||||
contentDecoderMap.put(ContentCoding.GZIP.token(), InflatingGzipDataConsumer::new);
|
contentDecoderMap.put(ContentCoding.GZIP.token(), InflatingGzipDataConsumer::new);
|
||||||
|
|
||||||
RedirectStrategy redirectStrategy =
|
RedirectStrategy redirectStrategy =
|
||||||
@@ -105,8 +100,7 @@ public class HttpClientFactory {
|
|||||||
.setDefaultHeaders(headers)
|
.setDefaultHeaders(headers)
|
||||||
.setConnectionManager(connectionManager)
|
.setConnectionManager(connectionManager)
|
||||||
.evictExpiredConnections()
|
.evictExpiredConnections()
|
||||||
.evictIdleConnections(
|
.evictIdleConnections(TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
|
||||||
TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
|
|
||||||
.setContentDecoderMap(new LinkedHashMap<>(contentDecoderMap))
|
.setContentDecoderMap(new LinkedHashMap<>(contentDecoderMap))
|
||||||
.setRedirectStrategy(redirectStrategy)
|
.setRedirectStrategy(redirectStrategy)
|
||||||
.build();
|
.build();
|
||||||
@@ -114,10 +108,8 @@ public class HttpClientFactory {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
private PoolingAsyncClientConnectionManager newConnectionManager(
|
private PoolingAsyncClientConnectionManager newConnectionManager(CommaFeedConfiguration config, int poolSize) {
|
||||||
CommaFeedConfiguration config, int poolSize) {
|
SSLFactory sslFactory = SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
|
||||||
SSLFactory sslFactory =
|
|
||||||
SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
|
|
||||||
DnsResolver dnsResolver =
|
DnsResolver dnsResolver =
|
||||||
config.httpClient().blockLocalAddresses()
|
config.httpClient().blockLocalAddresses()
|
||||||
? new BlockLocalAddressesDnsResolver(DNS_RESOLVER)
|
? new BlockLocalAddressesDnsResolver(DNS_RESOLVER)
|
||||||
@@ -129,14 +121,12 @@ public class HttpClientFactory {
|
|||||||
ConnectionConfig.custom()
|
ConnectionConfig.custom()
|
||||||
.setConnectTimeout(Timeout.of(config.httpClient().connectTimeout()))
|
.setConnectTimeout(Timeout.of(config.httpClient().connectTimeout()))
|
||||||
.setSocketTimeout(Timeout.of(config.httpClient().socketTimeout()))
|
.setSocketTimeout(Timeout.of(config.httpClient().socketTimeout()))
|
||||||
.setTimeToLive(
|
.setTimeToLive(Timeout.of(config.httpClient().connectionTimeToLive()))
|
||||||
Timeout.of(config.httpClient().connectionTimeToLive()))
|
|
||||||
.build())
|
.build())
|
||||||
.setDefaultTlsConfig(
|
.setDefaultTlsConfig(
|
||||||
TlsConfig.custom()
|
TlsConfig.custom()
|
||||||
.setVersionPolicy(HttpVersionPolicy.NEGOTIATE)
|
.setVersionPolicy(HttpVersionPolicy.NEGOTIATE)
|
||||||
.setHandshakeTimeout(
|
.setHandshakeTimeout(Timeout.of(config.httpClient().sslHandshakeTimeout()))
|
||||||
Timeout.of(config.httpClient().sslHandshakeTimeout()))
|
|
||||||
.build())
|
.build())
|
||||||
.setMaxConnPerRoute(poolSize)
|
.setMaxConnPerRoute(poolSize)
|
||||||
.setMaxConnTotal(poolSize)
|
.setMaxConnTotal(poolSize)
|
||||||
@@ -149,7 +139,11 @@ public class HttpClientFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isLocalAddress(IPAddress ip) {
|
private static boolean isLocalAddress(IPAddress ip) {
|
||||||
if (ip.isLocal() || ip.isLoopback() || ip.isMulticast() || CGNAT_RANGE.contains(ip)) {
|
if (ip.isLocal()
|
||||||
|
|| ip.isLoopback()
|
||||||
|
|| ip.isMulticast()
|
||||||
|
|| CGNAT_RANGE.contains(ip)
|
||||||
|
|| NAT64_LOCAL_USE_RANGE.contains(ip)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,8 +179,7 @@ public class HttpClientFactory {
|
|||||||
InetAddress[] addresses = delegate.resolve(host);
|
InetAddress[] addresses = delegate.resolve(host);
|
||||||
for (InetAddress addr : addresses) {
|
for (InetAddress addr : addresses) {
|
||||||
if (isLocalAddress(addr)) {
|
if (isLocalAddress(addr)) {
|
||||||
throw new UnknownHostException(
|
throw new UnknownHostException("Access to local address blocked: " + addr.getHostAddress());
|
||||||
"Access to local address blocked: " + addr.getHostAddress());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return addresses;
|
return addresses;
|
||||||
@@ -222,8 +215,7 @@ public class HttpClientFactory {
|
|||||||
|
|
||||||
for (InetAddress addr : addresses) {
|
for (InetAddress addr : addresses) {
|
||||||
if (isLocalAddress(addr)) {
|
if (isLocalAddress(addr)) {
|
||||||
throw new HttpException(
|
throw new HttpException("Access to local address blocked: " + addr.getHostAddress());
|
||||||
"Access to local address blocked: " + addr.getHostAddress());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,32 +75,22 @@ public class HttpGetter {
|
|||||||
this.client = httpClientFactory.newClient(config.feedRefresh().httpThreads());
|
this.client = httpClientFactory.newClient(config.feedRefresh().httpThreads());
|
||||||
this.cache = newCache(config);
|
this.cache = newCache(config);
|
||||||
|
|
||||||
metrics.registerGauge(
|
metrics.registerGauge(MetricRegistry.name(getClass(), "cache", "size"), () -> cache == null ? 0 : cache.size());
|
||||||
MetricRegistry.name(getClass(), "cache", "size"),
|
|
||||||
() -> cache == null ? 0 : cache.size());
|
|
||||||
metrics.registerGauge(
|
metrics.registerGauge(
|
||||||
MetricRegistry.name(getClass(), "cache", "memoryUsage"),
|
MetricRegistry.name(getClass(), "cache", "memoryUsage"),
|
||||||
() ->
|
() ->
|
||||||
cache == null
|
cache == null
|
||||||
? 0
|
? 0
|
||||||
: cache.asMap().values().stream()
|
: cache.asMap().values().stream().mapToInt(e -> ArrayUtils.getLength(e.content)).sum());
|
||||||
.mapToInt(e -> ArrayUtils.getLength(e.content))
|
|
||||||
.sum());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public HttpResult get(String url)
|
public HttpResult get(String url)
|
||||||
throws IOException,
|
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
|
||||||
NotModifiedException,
|
|
||||||
TooManyRequestsException,
|
|
||||||
SchemeNotAllowedException {
|
|
||||||
return get(HttpRequest.builder(url).build());
|
return get(HttpRequest.builder(url).build());
|
||||||
}
|
}
|
||||||
|
|
||||||
public HttpResult get(HttpRequest request)
|
public HttpResult get(HttpRequest request)
|
||||||
throws IOException,
|
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
|
||||||
NotModifiedException,
|
|
||||||
TooManyRequestsException,
|
|
||||||
SchemeNotAllowedException {
|
|
||||||
URI uri = URI.create(request.getUrl());
|
URI uri = URI.create(request.getUrl());
|
||||||
ensureHttpScheme(uri.getScheme());
|
ensureHttpScheme(uri.getScheme());
|
||||||
|
|
||||||
@@ -178,8 +168,7 @@ public class HttpGetter {
|
|||||||
FutureCallback<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> callback =
|
FutureCallback<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> callback =
|
||||||
new FutureCallback<>() {
|
new FutureCallback<>() {
|
||||||
@Override
|
@Override
|
||||||
public void completed(
|
public void completed(Message<org.apache.hc.core5.http.HttpResponse, byte[]> result) {}
|
||||||
Message<org.apache.hc.core5.http.HttpResponse, byte[]> result) {}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void failed(Exception ex) {}
|
public void failed(Exception ex) {}
|
||||||
@@ -283,15 +272,13 @@ public class HttpGetter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return CacheBuilder.newBuilder()
|
return CacheBuilder.newBuilder()
|
||||||
.weigher((HttpRequest key, HttpResponse value) ->
|
.weigher((HttpRequest key, HttpResponse value) -> value.content() != null ? value.content().length : 0)
|
||||||
value.content() != null ? value.content().length : 0)
|
|
||||||
.maximumWeight(cacheConfig.maximumMemorySize().asLongValue())
|
.maximumWeight(cacheConfig.maximumMemorySize().asLongValue())
|
||||||
.expireAfterWrite(cacheConfig.expiration())
|
.expireAfterWrite(cacheConfig.expiration())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private record LimitedContentLengthEntityConsumer(
|
private record LimitedContentLengthEntityConsumer(@Delegate AsyncEntityConsumer<byte[]> delegate, long maxBytes)
|
||||||
@Delegate AsyncEntityConsumer<byte[]> delegate, long maxBytes)
|
|
||||||
implements AsyncEntityConsumer<byte[]> {
|
implements AsyncEntityConsumer<byte[]> {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -324,9 +311,7 @@ public class HttpGetter {
|
|||||||
@Override
|
@Override
|
||||||
protected void data(ByteBuffer src, boolean endOfStream) throws IOException {
|
protected void data(ByteBuffer src, boolean endOfStream) throws IOException {
|
||||||
if (out.size() + src.remaining() > maxBytes) {
|
if (out.size() + src.remaining() > maxBytes) {
|
||||||
throw new IOException(
|
throw new IOException("Response size exceeds the maximum allowed size (%s bytes)".formatted(maxBytes));
|
||||||
"Response size exceeds the maximum allowed size (%s bytes)"
|
|
||||||
.formatted(maxBytes));
|
|
||||||
}
|
}
|
||||||
byte[] chunk = new byte[src.remaining()];
|
byte[] chunk = new byte[src.remaining()];
|
||||||
src.get(chunk);
|
src.get(chunk);
|
||||||
@@ -366,8 +351,7 @@ public class HttpGetter {
|
|||||||
this(message, null, null);
|
this(message, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public NotModifiedException(
|
public NotModifiedException(String message, String newLastModifiedHeader, String newEtagHeader) {
|
||||||
String message, String newLastModifiedHeader, String newEtagHeader) {
|
|
||||||
super(message);
|
super(message);
|
||||||
this.newLastModifiedHeader = newLastModifiedHeader;
|
this.newLastModifiedHeader = newLastModifiedHeader;
|
||||||
this.newEtagHeader = newEtagHeader;
|
this.newEtagHeader = newEtagHeader;
|
||||||
|
|||||||
@@ -65,11 +65,7 @@ public class Urls {
|
|||||||
try {
|
try {
|
||||||
return URI.create(baseUrl).resolve(relativeUrl).toString();
|
return URI.create(baseUrl).resolve(relativeUrl).toString();
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
log.debug(
|
log.debug("Unable to create absolute url from relative url: {} base: {}", relativeUrl, baseUrl, e);
|
||||||
"Unable to create absolute url from relative url: {} base: {}",
|
|
||||||
relativeUrl,
|
|
||||||
baseUrl,
|
|
||||||
e);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,8 +82,8 @@ public class Urls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize the url. The resulting url is not meant to be fetched but rather used as a mean to
|
* Normalize the url. The resulting url is not meant to be fetched but rather used as a mean to identify a feed and
|
||||||
* identify a feed and avoid duplicates
|
* avoid duplicates
|
||||||
*/
|
*/
|
||||||
public static String normalize(String url) {
|
public static String normalize(String url) {
|
||||||
if (url == null) {
|
if (url == null) {
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public FeedCategory findById(User user, Long id) {
|
public FeedCategory findById(User user, Long id) {
|
||||||
return query().selectFrom(CATEGORY)
|
return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), CATEGORY.id.eq(id)).fetchOne();
|
||||||
.where(CATEGORY.user.eq(user), CATEGORY.id.eq(id))
|
|
||||||
.fetchOne();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public FeedCategory findByName(User user, String name, FeedCategory parent) {
|
public FeedCategory findByName(User user, String name, FeedCategory parent) {
|
||||||
|
|||||||
@@ -36,10 +36,7 @@ public class FeedDAO extends GenericDAO<Feed> {
|
|||||||
// join on subscriptions to only refresh feeds that have subscribers
|
// join on subscriptions to only refresh feeds that have subscribers
|
||||||
.join(SUBSCRIPTION)
|
.join(SUBSCRIPTION)
|
||||||
.on(SUBSCRIPTION.feed.eq(FEED))
|
.on(SUBSCRIPTION.feed.eq(FEED))
|
||||||
.where(
|
.where(FEED.disabledUntil.isNull().or(FEED.disabledUntil.lt(Instant.now())));
|
||||||
FEED.disabledUntil
|
|
||||||
.isNull()
|
|
||||||
.or(FEED.disabledUntil.lt(Instant.now())));
|
|
||||||
|
|
||||||
if (lastLoginThreshold != null) {
|
if (lastLoginThreshold != null) {
|
||||||
query.join(SUBSCRIPTION.user).where(SUBSCRIPTION.user.lastLogin.gt(lastLoginThreshold));
|
query.join(SUBSCRIPTION.user).where(SUBSCRIPTION.user.lastLogin.gt(lastLoginThreshold));
|
||||||
@@ -53,11 +50,7 @@ public class FeedDAO extends GenericDAO<Feed> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Feed findByUrl(String normalizedUrl, String normalizedUrlHash) {
|
public Feed findByUrl(String normalizedUrl, String normalizedUrlHash) {
|
||||||
return query()
|
return query().selectFrom(FEED).where(FEED.normalizedUrlHash.eq(normalizedUrlHash)).fetch().stream()
|
||||||
.selectFrom(FEED)
|
|
||||||
.where(FEED.normalizedUrlHash.eq(normalizedUrlHash))
|
|
||||||
.fetch()
|
|
||||||
.stream()
|
|
||||||
.filter(f -> Strings.CS.equals(normalizedUrl, f.getNormalizedUrl()))
|
.filter(f -> Strings.CS.equals(normalizedUrl, f.getNormalizedUrl()))
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|||||||
@@ -42,8 +42,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Set<String> result = new HashSet<>();
|
Set<String> result = new HashSet<>();
|
||||||
for (List<String> batch :
|
for (List<String> batch : Lists.partition(new ArrayList<>(guidHashes), IN_CLAUSE_BATCH_SIZE)) {
|
||||||
Lists.partition(new ArrayList<>(guidHashes), IN_CLAUSE_BATCH_SIZE)) {
|
|
||||||
result.addAll(
|
result.addAll(
|
||||||
query().select(ENTRY.guidHash)
|
query().select(ENTRY.guidHash)
|
||||||
.from(ENTRY)
|
.from(ENTRY)
|
||||||
@@ -53,8 +52,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<FeedCapacity> findFeedsExceedingCapacity(
|
public List<FeedCapacity> findFeedsExceedingCapacity(long maxCapacity, long max, boolean keepStarredEntries) {
|
||||||
long maxCapacity, long max, boolean keepStarredEntries) {
|
|
||||||
NumberExpression<Long> count = ENTRY.id.count();
|
NumberExpression<Long> count = ENTRY.id.count();
|
||||||
JPAQuery<Tuple> query = query().select(ENTRY.feed.id, count).from(ENTRY);
|
JPAQuery<Tuple> query = query().select(ENTRY.feed.id, count).from(ENTRY);
|
||||||
|
|
||||||
@@ -68,8 +66,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public int delete(Long feedId, long max) {
|
public int delete(Long feedId, long max) {
|
||||||
List<FeedEntry> list =
|
List<FeedEntry> list = query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).limit(max).fetch();
|
||||||
query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).limit(max).fetch();
|
|
||||||
return delete(list);
|
return delete(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,10 +88,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
|
|||||||
/** Delete the oldest entries of a feed */
|
/** Delete the oldest entries of a feed */
|
||||||
public int deleteOldEntries(Long feedId, long max, boolean keepStarredEntries) {
|
public int deleteOldEntries(Long feedId, long max, boolean keepStarredEntries) {
|
||||||
JPAQuery<FeedEntry> query =
|
JPAQuery<FeedEntry> query =
|
||||||
query().selectFrom(ENTRY)
|
query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).orderBy(ENTRY.published.asc()).limit(max);
|
||||||
.where(ENTRY.feed.id.eq(feedId))
|
|
||||||
.orderBy(ENTRY.published.asc())
|
|
||||||
.limit(max);
|
|
||||||
|
|
||||||
if (keepStarredEntries) {
|
if (keepStarredEntries) {
|
||||||
query.where(Predicates.isNotStarred(ENTRY));
|
query.where(Predicates.isNotStarred(ENTRY));
|
||||||
|
|||||||
@@ -48,9 +48,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
|
|||||||
private final CommaFeedConfiguration config;
|
private final CommaFeedConfiguration config;
|
||||||
|
|
||||||
public FeedEntryStatusDAO(
|
public FeedEntryStatusDAO(
|
||||||
EntityManager entityManager,
|
EntityManager entityManager, FeedEntryTagDAO feedEntryTagDAO, CommaFeedConfiguration config) {
|
||||||
FeedEntryTagDAO feedEntryTagDAO,
|
|
||||||
CommaFeedConfiguration config) {
|
|
||||||
super(entityManager, FeedEntryStatus.class, config);
|
super(entityManager, FeedEntryStatus.class, config);
|
||||||
this.feedEntryTagDAO = feedEntryTagDAO;
|
this.feedEntryTagDAO = feedEntryTagDAO;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@@ -58,22 +56,16 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
|
|||||||
|
|
||||||
public FeedEntryStatus getStatus(User user, FeedSubscription sub, FeedEntry entry) {
|
public FeedEntryStatus getStatus(User user, FeedSubscription sub, FeedEntry entry) {
|
||||||
List<FeedEntryStatus> statuses =
|
List<FeedEntryStatus> statuses =
|
||||||
query().selectFrom(STATUS)
|
query().selectFrom(STATUS).where(STATUS.entry.eq(entry), STATUS.subscription.eq(sub)).fetch();
|
||||||
.where(STATUS.entry.eq(entry), STATUS.subscription.eq(sub))
|
|
||||||
.fetch();
|
|
||||||
FeedEntryStatus status = statuses.stream().findFirst().orElse(null);
|
FeedEntryStatus status = statuses.stream().findFirst().orElse(null);
|
||||||
return handleStatus(user, status, sub, entry);
|
return handleStatus(user, status, sub, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** creates an artificial "unread" status if status is null */
|
/** creates an artificial "unread" status if status is null */
|
||||||
private FeedEntryStatus handleStatus(
|
private FeedEntryStatus handleStatus(User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) {
|
||||||
User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) {
|
|
||||||
if (status == null) {
|
if (status == null) {
|
||||||
Instant statusesInstantThreshold =
|
Instant statusesInstantThreshold = config.database().cleanup().statusesInstantThreshold();
|
||||||
config.database().cleanup().statusesInstantThreshold();
|
boolean read = statusesInstantThreshold != null && entry.getPublished().isBefore(statusesInstantThreshold);
|
||||||
boolean read =
|
|
||||||
statusesInstantThreshold != null
|
|
||||||
&& entry.getPublished().isBefore(statusesInstantThreshold);
|
|
||||||
status = new FeedEntryStatus(user, sub, entry);
|
status = new FeedEntryStatus(user, sub, entry);
|
||||||
status.setRead(read);
|
status.setRead(read);
|
||||||
status.setMarkable(!read);
|
status.setMarkable(!read);
|
||||||
@@ -85,8 +77,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
|
|||||||
|
|
||||||
private void fetchTags(User user, List<FeedEntryStatus> statuses) {
|
private void fetchTags(User user, List<FeedEntryStatus> statuses) {
|
||||||
Map<Long, List<FeedEntryTag>> tagsByEntryIds =
|
Map<Long, List<FeedEntryTag>> tagsByEntryIds =
|
||||||
feedEntryTagDAO.findByEntries(
|
feedEntryTagDAO.findByEntries(user, statuses.stream().map(FeedEntryStatus::getEntry).toList());
|
||||||
user, statuses.stream().map(FeedEntryStatus::getEntry).toList());
|
|
||||||
for (FeedEntryStatus status : statuses) {
|
for (FeedEntryStatus status : statuses) {
|
||||||
List<FeedEntryTag> tags = tagsByEntryIds.get(status.getEntry().getId());
|
List<FeedEntryTag> tags = tagsByEntryIds.get(status.getEntry().getId());
|
||||||
status.setTags(tags == null ? List.of() : tags);
|
status.setTags(tags == null ? List.of() : tags);
|
||||||
@@ -258,9 +249,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
|
|||||||
Long count = tuple.get(ENTRY.count());
|
Long count = tuple.get(ENTRY.count());
|
||||||
Instant published = tuple.get(ENTRY.published.max());
|
Instant published = tuple.get(ENTRY.published.max());
|
||||||
for (FeedSubscription sub : subsByFeedId.get(feedId)) {
|
for (FeedSubscription sub : subsByFeedId.get(feedId)) {
|
||||||
result.put(
|
result.put(sub.getId(), new UnreadCount(sub.getId(), count == null ? 0 : count, published));
|
||||||
sub.getId(),
|
|
||||||
new UnreadCount(sub.getId(), count == null ? 0 : count, published));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,8 +292,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
|
|||||||
where.and(SUBSCRIPTION.autoMarkAsReadAfterDays.gt(0));
|
where.and(SUBSCRIPTION.autoMarkAsReadAfterDays.gt(0));
|
||||||
|
|
||||||
NumberExpression<Integer> daysDiff =
|
NumberExpression<Integer> daysDiff =
|
||||||
Expressions.numberTemplate(
|
Expressions.numberTemplate(Integer.class, "TIMESTAMPDIFF(DAY, {0}, {1})", ENTRY.published, now);
|
||||||
Integer.class, "TIMESTAMPDIFF(DAY, {0}, {1})", ENTRY.published, now);
|
|
||||||
where.and(daysDiff.goe(SUBSCRIPTION.autoMarkAsReadAfterDays));
|
where.and(daysDiff.goe(SUBSCRIPTION.autoMarkAsReadAfterDays));
|
||||||
|
|
||||||
where.and(buildUnreadPredicate());
|
where.and(buildUnreadPredicate());
|
||||||
@@ -333,11 +321,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (!statusIdsToUpdate.isEmpty()) {
|
if (!statusIdsToUpdate.isEmpty()) {
|
||||||
updated +=
|
updated += updateQuery(STATUS).where(STATUS.id.in(statusIdsToUpdate)).set(STATUS.read, true).execute();
|
||||||
updateQuery(STATUS)
|
|
||||||
.where(STATUS.id.in(statusIdsToUpdate))
|
|
||||||
.set(STATUS.read, true)
|
|
||||||
.execute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert new statuses for entries without existing status
|
// Insert new statuses for entries without existing status
|
||||||
|
|||||||
@@ -31,11 +31,7 @@ public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Map<Long, List<FeedEntryTag>> findByEntries(User user, List<FeedEntry> entries) {
|
public Map<Long, List<FeedEntryTag>> findByEntries(User user, List<FeedEntry> entries) {
|
||||||
return query()
|
return query().selectFrom(TAG).where(TAG.user.eq(user), TAG.entry.in(entries)).fetch().stream()
|
||||||
.selectFrom(TAG)
|
|
||||||
.where(TAG.user.eq(user), TAG.entry.in(entries))
|
|
||||||
.fetch()
|
|
||||||
.stream()
|
|
||||||
.collect(Collectors.groupingBy(t -> t.getEntry().getId()));
|
.collect(Collectors.groupingBy(t -> t.getEntry().getId()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,9 +84,7 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
|
|||||||
|
|
||||||
public FeedSubscription findByFeed(User user, Feed feed) {
|
public FeedSubscription findByFeed(User user, Feed feed) {
|
||||||
List<FeedSubscription> subs =
|
List<FeedSubscription> subs =
|
||||||
query().selectFrom(SUBSCRIPTION)
|
query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.feed.eq(feed)).fetch();
|
||||||
.where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.feed.eq(feed))
|
|
||||||
.fetch();
|
|
||||||
FeedSubscription sub = subs.stream().findFirst().orElse(null);
|
FeedSubscription sub = subs.stream().findFirst().orElse(null);
|
||||||
return initRelations(sub);
|
return initRelations(sub);
|
||||||
}
|
}
|
||||||
@@ -104,15 +102,11 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Long count(User user) {
|
public Long count(User user) {
|
||||||
return query().select(SUBSCRIPTION.count())
|
return query().select(SUBSCRIPTION.count()).from(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user)).fetchOne();
|
||||||
.from(SUBSCRIPTION)
|
|
||||||
.where(SUBSCRIPTION.user.eq(user))
|
|
||||||
.fetchOne();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<FeedSubscription> findByCategory(User user, FeedCategory category) {
|
public List<FeedSubscription> findByCategory(User user, FeedCategory category) {
|
||||||
JPQLQuery<FeedSubscription> query =
|
JPQLQuery<FeedSubscription> query = query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user));
|
||||||
query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user));
|
|
||||||
if (category == null) {
|
if (category == null) {
|
||||||
query.where(SUBSCRIPTION.category.isNull());
|
query.where(SUBSCRIPTION.category.isNull());
|
||||||
} else {
|
} else {
|
||||||
@@ -122,11 +116,9 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<FeedSubscription> findByCategories(User user, List<FeedCategory> categories) {
|
public List<FeedSubscription> findByCategories(User user, List<FeedCategory> categories) {
|
||||||
Set<Long> categoryIds =
|
Set<Long> categoryIds = categories.stream().map(AbstractModel::getId).collect(Collectors.toSet());
|
||||||
categories.stream().map(AbstractModel::getId).collect(Collectors.toSet());
|
|
||||||
return findAll(user).stream()
|
return findAll(user).stream()
|
||||||
.filter(s ->
|
.filter(s -> s.getCategory() != null && categoryIds.contains(s.getCategory().getId()))
|
||||||
s.getCategory() != null && categoryIds.contains(s.getCategory().getId()))
|
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,7 @@ public class FacebookFaviconFetcher implements FaviconFetcher {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String iconUrl =
|
String iconUrl = String.format("https://graph.facebook.com/%s/picture?type=square&height=16", userName);
|
||||||
String.format(
|
|
||||||
"https://graph.facebook.com/%s/picture?type=square&height=16", userName);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log.debug("Getting Facebook user's icon, {}", url);
|
log.debug("Getting Facebook user's icon, {}", url);
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ public record Favicon(byte[] icon, MediaType mediaType) {
|
|||||||
try {
|
try {
|
||||||
return MediaType.valueOf(contentType);
|
return MediaType.valueOf(contentType);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug(
|
log.debug("invalid content type '{}' received, returning default value", contentType, e);
|
||||||
"invalid content type '{}' received, returning default value", contentType, e);
|
|
||||||
return DEFAULT_MEDIA_TYPE;
|
return DEFAULT_MEDIA_TYPE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,10 +32,7 @@ public class RootFaviconFetcher implements FaviconFetcher {
|
|||||||
URI uri = URI.create(url.trim());
|
URI uri = URI.create(url.trim());
|
||||||
String faviconUrl =
|
String faviconUrl =
|
||||||
"%s://%s%s/favicon.ico"
|
"%s://%s%s/favicon.ico"
|
||||||
.formatted(
|
.formatted(uri.getScheme(), uri.getHost(), uri.getPort() > 0 ? ":" + uri.getPort() : "");
|
||||||
uri.getScheme(),
|
|
||||||
uri.getHost(),
|
|
||||||
uri.getPort() > 0 ? ":" + uri.getPort() : "");
|
|
||||||
|
|
||||||
log.debug("getting root icon at {}", faviconUrl);
|
log.debug("getting root icon at {}", faviconUrl);
|
||||||
HttpResult result = getter.get(faviconUrl);
|
HttpResult result = getter.get(faviconUrl);
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
|
|||||||
|
|
||||||
private static final JsonPointer CHANNEL_THUMBNAIL_URL =
|
private static final JsonPointer CHANNEL_THUMBNAIL_URL =
|
||||||
JsonPointer.compile("/items/0/snippet/thumbnails/default/url");
|
JsonPointer.compile("/items/0/snippet/thumbnails/default/url");
|
||||||
private static final JsonPointer PLAYLIST_CHANNEL_ID =
|
private static final JsonPointer PLAYLIST_CHANNEL_ID = JsonPointer.compile("/items/0/snippet/channelId");
|
||||||
JsonPointer.compile("/items/0/snippet/channelId");
|
|
||||||
|
|
||||||
private final HttpGetter getter;
|
private final HttpGetter getter;
|
||||||
private final CommaFeedConfiguration config;
|
private final CommaFeedConfiguration config;
|
||||||
@@ -60,17 +59,11 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
|
|||||||
try {
|
try {
|
||||||
List<NameValuePair> params = new URIBuilder(url).getQueryParams();
|
List<NameValuePair> params = new URIBuilder(url).getQueryParams();
|
||||||
Optional<NameValuePair> userId =
|
Optional<NameValuePair> userId =
|
||||||
params.stream()
|
params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("user")).findFirst();
|
||||||
.filter(nvp -> nvp.getName().equalsIgnoreCase("user"))
|
|
||||||
.findFirst();
|
|
||||||
Optional<NameValuePair> channelId =
|
Optional<NameValuePair> channelId =
|
||||||
params.stream()
|
params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("channel_id")).findFirst();
|
||||||
.filter(nvp -> nvp.getName().equalsIgnoreCase("channel_id"))
|
|
||||||
.findFirst();
|
|
||||||
Optional<NameValuePair> playlistId =
|
Optional<NameValuePair> playlistId =
|
||||||
params.stream()
|
params.stream().filter(nvp -> nvp.getName().equalsIgnoreCase("playlist_id")).findFirst();
|
||||||
.filter(nvp -> nvp.getName().equalsIgnoreCase("playlist_id"))
|
|
||||||
.findFirst();
|
|
||||||
|
|
||||||
byte[] response = null;
|
byte[] response = null;
|
||||||
if (userId.isPresent()) {
|
if (userId.isPresent()) {
|
||||||
@@ -103,10 +96,7 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private byte[] fetchForUser(String googleAuthKey, String userId)
|
private byte[] fetchForUser(String googleAuthKey, String userId)
|
||||||
throws IOException,
|
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
|
||||||
NotModifiedException,
|
|
||||||
TooManyRequestsException,
|
|
||||||
SchemeNotAllowedException {
|
|
||||||
URI uri =
|
URI uri =
|
||||||
UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
|
UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
|
||||||
.queryParam("part", PART_SNIPPET)
|
.queryParam("part", PART_SNIPPET)
|
||||||
@@ -117,10 +107,7 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private byte[] fetchForChannel(String googleAuthKey, String channelId)
|
private byte[] fetchForChannel(String googleAuthKey, String channelId)
|
||||||
throws IOException,
|
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
|
||||||
NotModifiedException,
|
|
||||||
TooManyRequestsException,
|
|
||||||
SchemeNotAllowedException {
|
|
||||||
URI uri =
|
URI uri =
|
||||||
UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
|
UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/channels")
|
||||||
.queryParam("part", PART_SNIPPET)
|
.queryParam("part", PART_SNIPPET)
|
||||||
@@ -131,10 +118,7 @@ public class YoutubeFaviconFetcher implements FaviconFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private byte[] fetchForPlaylist(String googleAuthKey, String playlistId)
|
private byte[] fetchForPlaylist(String googleAuthKey, String playlistId)
|
||||||
throws IOException,
|
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
|
||||||
NotModifiedException,
|
|
||||||
TooManyRequestsException,
|
|
||||||
SchemeNotAllowedException {
|
|
||||||
URI uri =
|
URI uri =
|
||||||
UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/playlists")
|
UriBuilder.fromUri("https://www.googleapis.com/youtube/v3/playlists")
|
||||||
.queryParam("part", PART_SNIPPET)
|
.queryParam("part", PART_SNIPPET)
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ public class FeedFetcher {
|
|||||||
private final HttpGetter getter;
|
private final HttpGetter getter;
|
||||||
private final List<FeedURLProvider> urlProviders;
|
private final List<FeedURLProvider> urlProviders;
|
||||||
|
|
||||||
public FeedFetcher(
|
public FeedFetcher(FeedParser parser, HttpGetter getter, @All List<FeedURLProvider> urlProviders) {
|
||||||
FeedParser parser, HttpGetter getter, @All List<FeedURLProvider> urlProviders) {
|
|
||||||
this.parser = parser;
|
this.parser = parser;
|
||||||
this.getter = getter;
|
this.getter = getter;
|
||||||
this.urlProviders = urlProviders;
|
this.urlProviders = urlProviders;
|
||||||
@@ -58,9 +57,7 @@ public class FeedFetcher {
|
|||||||
NoFeedFoundException {
|
NoFeedFoundException {
|
||||||
log.debug("Fetching feed {}", feedUrl);
|
log.debug("Fetching feed {}", feedUrl);
|
||||||
|
|
||||||
HttpResult result =
|
HttpResult result = getter.get(HttpRequest.builder(feedUrl).lastModified(lastModified).eTag(eTag).build());
|
||||||
getter.get(
|
|
||||||
HttpRequest.builder(feedUrl).lastModified(lastModified).eTag(eTag).build());
|
|
||||||
byte[] content = result.content();
|
byte[] content = result.content();
|
||||||
|
|
||||||
FeedParserResult parserResult;
|
FeedParserResult parserResult;
|
||||||
@@ -69,19 +66,12 @@ public class FeedFetcher {
|
|||||||
} catch (FeedParsingException e) {
|
} catch (FeedParsingException e) {
|
||||||
if (extractFeedUrlFromHtml) {
|
if (extractFeedUrlFromHtml) {
|
||||||
String extractedUrl =
|
String extractedUrl =
|
||||||
extractFeedUrl(
|
extractFeedUrl(urlProviders, feedUrl, new String(result.content(), StandardCharsets.UTF_8));
|
||||||
urlProviders,
|
|
||||||
feedUrl,
|
|
||||||
new String(result.content(), StandardCharsets.UTF_8));
|
|
||||||
if (StringUtils.isNotBlank(extractedUrl)) {
|
if (StringUtils.isNotBlank(extractedUrl)) {
|
||||||
feedUrl = extractedUrl;
|
feedUrl = extractedUrl;
|
||||||
|
|
||||||
result =
|
result =
|
||||||
getter.get(
|
getter.get(HttpRequest.builder(extractedUrl).lastModified(lastModified).eTag(eTag).build());
|
||||||
HttpRequest.builder(extractedUrl)
|
|
||||||
.lastModified(lastModified)
|
|
||||||
.eTag(eTag)
|
|
||||||
.build());
|
|
||||||
content = result.content();
|
content = result.content();
|
||||||
parserResult = parser.parse(result.urlAfterRedirect(), content);
|
parserResult = parser.parse(result.urlAfterRedirect(), content);
|
||||||
} else {
|
} else {
|
||||||
@@ -96,8 +86,7 @@ public class FeedFetcher {
|
|||||||
throw new IOException("Feed content is empty.");
|
throw new IOException("Feed content is empty.");
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean lastModifiedHeaderValueChanged =
|
boolean lastModifiedHeaderValueChanged = !Strings.CS.equals(lastModified, result.lastModifiedSince());
|
||||||
!Strings.CS.equals(lastModified, result.lastModifiedSince());
|
|
||||||
boolean etagHeaderValueChanged = !Strings.CS.equals(eTag, result.eTag());
|
boolean etagHeaderValueChanged = !Strings.CS.equals(eTag, result.eTag());
|
||||||
|
|
||||||
String hash = Digests.sha1Hex(content);
|
String hash = Digests.sha1Hex(content);
|
||||||
@@ -109,8 +98,7 @@ public class FeedFetcher {
|
|||||||
etagHeaderValueChanged ? result.eTag() : null);
|
etagHeaderValueChanged ? result.eTag() : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastPublishedDate != null
|
if (lastPublishedDate != null && lastPublishedDate.equals(parserResult.lastPublishedDate())) {
|
||||||
&& lastPublishedDate.equals(parserResult.lastPublishedDate())) {
|
|
||||||
log.debug("publishedDate not modified: {}", feedUrl);
|
log.debug("publishedDate not modified: {}", feedUrl);
|
||||||
throw new NotModifiedException(
|
throw new NotModifiedException(
|
||||||
"publishedDate not modified",
|
"publishedDate not modified",
|
||||||
@@ -127,8 +115,7 @@ public class FeedFetcher {
|
|||||||
result.validFor());
|
result.validFor());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String extractFeedUrl(
|
private static String extractFeedUrl(List<FeedURLProvider> urlProviders, String url, String urlContent) {
|
||||||
List<FeedURLProvider> urlProviders, String url, String urlContent) {
|
|
||||||
return urlProviders.stream()
|
return urlProviders.stream()
|
||||||
.flatMap(provider -> provider.get(url, urlContent).stream())
|
.flatMap(provider -> provider.get(url, urlContent).stream())
|
||||||
.filter(StringUtils::isNotBlank)
|
.filter(StringUtils::isNotBlank)
|
||||||
|
|||||||
@@ -67,8 +67,7 @@ public class FeedRefreshEngine {
|
|||||||
|
|
||||||
this.queue = new LinkedBlockingDeque<>();
|
this.queue = new LinkedBlockingDeque<>();
|
||||||
|
|
||||||
metrics.register(
|
metrics.register(MetricRegistry.name(getClass(), "queue", "size"), (Gauge<Integer>) queue::size);
|
||||||
MetricRegistry.name(getClass(), "queue", "size"), (Gauge<Integer>) queue::size);
|
|
||||||
metrics.register(
|
metrics.register(
|
||||||
MetricRegistry.name(getClass(), "worker", "active"),
|
MetricRegistry.name(getClass(), "worker", "active"),
|
||||||
(Gauge<Integer>) () -> workerExecutor.getActiveCount());
|
(Gauge<Integer>) () -> workerExecutor.getActiveCount());
|
||||||
@@ -88,12 +87,10 @@ public class FeedRefreshEngine {
|
|||||||
this.refillLoopExecutor = Executors.newSingleThreadExecutor();
|
this.refillLoopExecutor = Executors.newSingleThreadExecutor();
|
||||||
this.refillExecutor = newDiscardingSingleThreadExecutorService();
|
this.refillExecutor = newDiscardingSingleThreadExecutorService();
|
||||||
this.workerExecutor = newBlockingExecutorService(config.feedRefresh().httpThreads());
|
this.workerExecutor = newBlockingExecutorService(config.feedRefresh().httpThreads());
|
||||||
this.databaseUpdaterExecutor =
|
this.databaseUpdaterExecutor = newBlockingExecutorService(config.feedRefresh().databaseThreads());
|
||||||
newBlockingExecutorService(config.feedRefresh().databaseThreads());
|
|
||||||
this.notifierExecutor =
|
this.notifierExecutor =
|
||||||
newDiscardingExecutorService(
|
newDiscardingExecutorService(
|
||||||
config.pushNotifications().threads(),
|
config.pushNotifications().threads(), config.pushNotifications().queueCapacity());
|
||||||
config.pushNotifications().queueCapacity());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void start() {
|
public void start() {
|
||||||
@@ -169,9 +166,7 @@ public class FeedRefreshEngine {
|
|||||||
refill.mark();
|
refill.mark();
|
||||||
|
|
||||||
List<Feed> nextUpdatableFeeds = getNextUpdatableFeeds(getBatchSize());
|
List<Feed> nextUpdatableFeeds = getNextUpdatableFeeds(getBatchSize());
|
||||||
log.debug(
|
log.debug("found {} feeds that are up for refresh", nextUpdatableFeeds.size());
|
||||||
"found {} feeds that are up for refresh",
|
|
||||||
nextUpdatableFeeds.size());
|
|
||||||
for (Feed feed : nextUpdatableFeeds) {
|
for (Feed feed : nextUpdatableFeeds) {
|
||||||
// add the feed only if it was not already queued
|
// add the feed only if it was not already queued
|
||||||
if (queue.stream().noneMatch(f -> f.getId().equals(feed.getId()))) {
|
if (queue.stream().noneMatch(f -> f.getId().equals(feed.getId()))) {
|
||||||
@@ -199,8 +194,7 @@ public class FeedRefreshEngine {
|
|||||||
|
|
||||||
notifier.notifyOverWebsocket(sub, entries);
|
notifier.notifyOverWebsocket(sub, entries);
|
||||||
return CompletableFuture.runAsync(
|
return CompletableFuture.runAsync(
|
||||||
() -> notifier.sendPushNotifications(sub, entries),
|
() -> notifier.sendPushNotifications(sub, entries), notifierExecutor);
|
||||||
notifierExecutor);
|
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new));
|
return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new));
|
||||||
@@ -222,8 +216,7 @@ public class FeedRefreshEngine {
|
|||||||
// update disabledUntil to prevent feeds from being returned again by
|
// update disabledUntil to prevent feeds from being returned again by
|
||||||
// feedDAO.findNextUpdatable()
|
// feedDAO.findNextUpdatable()
|
||||||
Instant nextUpdateDate = Instant.now().plus(config.feedRefresh().interval());
|
Instant nextUpdateDate = Instant.now().plus(config.feedRefresh().interval());
|
||||||
feedDAO.setDisabledUntil(
|
feedDAO.setDisabledUntil(feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate);
|
||||||
feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate);
|
|
||||||
}
|
}
|
||||||
return feeds;
|
return feeds;
|
||||||
});
|
});
|
||||||
@@ -234,26 +227,19 @@ public class FeedRefreshEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void stop() {
|
public void stop() {
|
||||||
MoreExecutors.shutdownAndAwaitTermination(
|
MoreExecutors.shutdownAndAwaitTermination(this.feedProcessingLoopExecutor, config.shutdownTimeout());
|
||||||
this.feedProcessingLoopExecutor, config.shutdownTimeout());
|
MoreExecutors.shutdownAndAwaitTermination(this.refillLoopExecutor, config.shutdownTimeout());
|
||||||
MoreExecutors.shutdownAndAwaitTermination(
|
|
||||||
this.refillLoopExecutor, config.shutdownTimeout());
|
|
||||||
MoreExecutors.shutdownAndAwaitTermination(this.refillExecutor, config.shutdownTimeout());
|
MoreExecutors.shutdownAndAwaitTermination(this.refillExecutor, config.shutdownTimeout());
|
||||||
MoreExecutors.shutdownAndAwaitTermination(this.workerExecutor, config.shutdownTimeout());
|
MoreExecutors.shutdownAndAwaitTermination(this.workerExecutor, config.shutdownTimeout());
|
||||||
MoreExecutors.shutdownAndAwaitTermination(
|
MoreExecutors.shutdownAndAwaitTermination(this.databaseUpdaterExecutor, config.shutdownTimeout());
|
||||||
this.databaseUpdaterExecutor, config.shutdownTimeout());
|
|
||||||
MoreExecutors.shutdownAndAwaitTermination(this.notifierExecutor, config.shutdownTimeout());
|
MoreExecutors.shutdownAndAwaitTermination(this.notifierExecutor, config.shutdownTimeout());
|
||||||
|
|
||||||
queue.clear();
|
queue.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** returns an ExecutorService with a single thread that discards tasks if a task is already running */
|
||||||
* returns an ExecutorService with a single thread that discards tasks if a task is already
|
|
||||||
* running
|
|
||||||
*/
|
|
||||||
private ThreadPoolExecutor newDiscardingSingleThreadExecutorService() {
|
private ThreadPoolExecutor newDiscardingSingleThreadExecutorService() {
|
||||||
ThreadPoolExecutor pool =
|
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
|
||||||
new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
|
|
||||||
pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
|
pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
|
||||||
return pool;
|
return pool;
|
||||||
}
|
}
|
||||||
@@ -262,11 +248,7 @@ public class FeedRefreshEngine {
|
|||||||
private ThreadPoolExecutor newDiscardingExecutorService(int threads, int queueCapacity) {
|
private ThreadPoolExecutor newDiscardingExecutorService(int threads, int queueCapacity) {
|
||||||
ThreadPoolExecutor pool =
|
ThreadPoolExecutor pool =
|
||||||
new ThreadPoolExecutor(
|
new ThreadPoolExecutor(
|
||||||
threads,
|
threads, threads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(queueCapacity));
|
||||||
threads,
|
|
||||||
0L,
|
|
||||||
TimeUnit.MILLISECONDS,
|
|
||||||
new LinkedBlockingQueue<>(queueCapacity));
|
|
||||||
pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
|
pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
|
||||||
return pool;
|
return pool;
|
||||||
}
|
}
|
||||||
@@ -274,8 +256,7 @@ public class FeedRefreshEngine {
|
|||||||
/** returns an ExecutorService that blocks submissions until a thread is available */
|
/** returns an ExecutorService that blocks submissions until a thread is available */
|
||||||
private ThreadPoolExecutor newBlockingExecutorService(int threads) {
|
private ThreadPoolExecutor newBlockingExecutorService(int threads) {
|
||||||
ThreadPoolExecutor pool =
|
ThreadPoolExecutor pool =
|
||||||
new ThreadPoolExecutor(
|
new ThreadPoolExecutor(threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
|
||||||
threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
|
|
||||||
pool.setRejectedExecutionHandler((r, e) -> {
|
pool.setRejectedExecutionHandler((r, e) -> {
|
||||||
if (e.isShutdown()) {
|
if (e.isShutdown()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ public class FeedRefreshIntervalCalculator {
|
|||||||
private final FeedRefreshErrorHandling errorHandling;
|
private final FeedRefreshErrorHandling errorHandling;
|
||||||
private final InstantSource instantSource;
|
private final InstantSource instantSource;
|
||||||
|
|
||||||
public FeedRefreshIntervalCalculator(
|
public FeedRefreshIntervalCalculator(CommaFeedConfiguration config, InstantSource instantSource) {
|
||||||
CommaFeedConfiguration config, InstantSource instantSource) {
|
|
||||||
this.interval = config.feedRefresh().interval();
|
this.interval = config.feedRefresh().interval();
|
||||||
this.maxInterval = config.feedRefresh().maxInterval();
|
this.maxInterval = config.feedRefresh().maxInterval();
|
||||||
this.empirical = config.feedRefresh().intervalEmpirical();
|
this.empirical = config.feedRefresh().intervalEmpirical();
|
||||||
@@ -31,8 +30,7 @@ public class FeedRefreshIntervalCalculator {
|
|||||||
this.instantSource = instantSource;
|
this.instantSource = instantSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Instant onFetchSuccess(
|
public Instant onFetchSuccess(Instant publishedDate, Long averageEntryInterval, Duration validFor) {
|
||||||
Instant publishedDate, Long averageEntryInterval, Duration validFor) {
|
|
||||||
Instant instant =
|
Instant instant =
|
||||||
empirical
|
empirical
|
||||||
? computeEmpiricalRefreshInterval(publishedDate, averageEntryInterval)
|
? computeEmpiricalRefreshInterval(publishedDate, averageEntryInterval)
|
||||||
@@ -54,14 +52,11 @@ public class FeedRefreshIntervalCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Duration retryInterval =
|
Duration retryInterval =
|
||||||
errorHandling
|
errorHandling.backoffInterval().multipliedBy(errorCount - errorHandling.retriesBeforeBackoff() + 1L);
|
||||||
.backoffInterval()
|
|
||||||
.multipliedBy(errorCount - errorHandling.retriesBeforeBackoff() + 1L);
|
|
||||||
return constrainToBounds(instantSource.instant().plus(retryInterval));
|
return constrainToBounds(instantSource.instant().plus(retryInterval));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Instant computeEmpiricalRefreshInterval(
|
private Instant computeEmpiricalRefreshInterval(Instant publishedDate, Long averageEntryInterval) {
|
||||||
Instant publishedDate, Long averageEntryInterval) {
|
|
||||||
Instant now = instantSource.instant();
|
Instant now = instantSource.instant();
|
||||||
|
|
||||||
if (publishedDate == null) {
|
if (publishedDate == null) {
|
||||||
@@ -80,9 +75,7 @@ public class FeedRefreshIntervalCalculator {
|
|||||||
int factor = 2;
|
int factor = 2;
|
||||||
long millis =
|
long millis =
|
||||||
Longs.constrainToRange(
|
Longs.constrainToRange(
|
||||||
averageEntryInterval / factor,
|
averageEntryInterval / factor, interval.toMillis(), maxInterval.dividedBy(4).toMillis());
|
||||||
interval.toMillis(),
|
|
||||||
maxInterval.dividedBy(4).toMillis());
|
|
||||||
return now.plusMillis(millis);
|
return now.plusMillis(millis);
|
||||||
} else {
|
} else {
|
||||||
// unknown case
|
// unknown case
|
||||||
|
|||||||
@@ -64,8 +64,7 @@ public class FeedRefreshUpdater {
|
|||||||
entryInserted = metrics.meter(MetricRegistry.name(getClass(), "entryInserted"));
|
entryInserted = metrics.meter(MetricRegistry.name(getClass(), "entryInserted"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private AddEntryResult addEntry(
|
private AddEntryResult addEntry(final Feed feed, final Entry entry, final List<FeedSubscription> subscriptions) {
|
||||||
final Feed feed, final Entry entry, final List<FeedSubscription> subscriptions) {
|
|
||||||
boolean processed = false;
|
boolean processed = false;
|
||||||
FeedEntry insertedEntry = null;
|
FeedEntry insertedEntry = null;
|
||||||
Set<FeedSubscription> subscriptionsForWhichEntryIsUnread = new HashSet<>();
|
Set<FeedSubscription> subscriptionsForWhichEntryIsUnread = new HashSet<>();
|
||||||
@@ -111,11 +110,7 @@ public class FeedRefreshUpdater {
|
|||||||
log.error("lock timeout for {} - {}", feed.getUrl(), key1);
|
log.error("lock timeout for {} - {}", feed.getUrl(), key1);
|
||||||
}
|
}
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
log.error(
|
log.error("interrupted while waiting for lock for {} : {}", feed.getUrl(), e.getMessage(), e);
|
||||||
"interrupted while waiting for lock for {} : {}",
|
|
||||||
feed.getUrl(),
|
|
||||||
e.getMessage(),
|
|
||||||
e);
|
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
} finally {
|
} finally {
|
||||||
if (locked1) {
|
if (locked1) {
|
||||||
@@ -131,13 +126,11 @@ public class FeedRefreshUpdater {
|
|||||||
public FeedRefreshUpdaterResult update(Feed feed, List<Entry> entries) {
|
public FeedRefreshUpdaterResult update(Feed feed, List<Entry> entries) {
|
||||||
boolean processed = true;
|
boolean processed = true;
|
||||||
long inserted = 0;
|
long inserted = 0;
|
||||||
Map<FeedSubscription, List<FeedEntry>> insertedUnreadEntriesBySubscription =
|
Map<FeedSubscription, List<FeedEntry>> insertedUnreadEntriesBySubscription = new HashMap<>();
|
||||||
new HashMap<>();
|
|
||||||
|
|
||||||
if (!entries.isEmpty()) {
|
if (!entries.isEmpty()) {
|
||||||
List<FeedSubscription> subscriptions = null;
|
List<FeedSubscription> subscriptions = null;
|
||||||
List<Entry> newEntries =
|
List<Entry> newEntries = unitOfWork.call(() -> feedEntryService.removeExistingEntries(feed, entries));
|
||||||
unitOfWork.call(() -> feedEntryService.removeExistingEntries(feed, entries));
|
|
||||||
for (Entry entry : newEntries) {
|
for (Entry entry : newEntries) {
|
||||||
if (subscriptions == null) {
|
if (subscriptions == null) {
|
||||||
subscriptions = unitOfWork.call(() -> feedSubscriptionDAO.findByFeed(feed));
|
subscriptions = unitOfWork.call(() -> feedSubscriptionDAO.findByFeed(feed));
|
||||||
@@ -176,9 +169,7 @@ public class FeedRefreshUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private record AddEntryResult(
|
private record AddEntryResult(
|
||||||
boolean processed,
|
boolean processed, FeedEntry insertedEntry, Set<FeedSubscription> subscriptionsForWhichEntryIsUnread) {}
|
||||||
FeedEntry insertedEntry,
|
|
||||||
Set<FeedSubscription> subscriptionsForWhichEntryIsUnread) {}
|
|
||||||
|
|
||||||
public record FeedRefreshUpdaterResult(
|
public record FeedRefreshUpdaterResult(
|
||||||
Map<FeedSubscription, List<FeedEntry>> insertedUnreadEntriesBySubscription) {}
|
Map<FeedSubscription, List<FeedEntry>> insertedUnreadEntriesBySubscription) {}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls {@link FeedFetcher} and updates the Feed object, but does not update the database, ({@link
|
* Calls {@link FeedFetcher} and updates the Feed object, but does not update the database, ({@link FeedRefreshUpdater}
|
||||||
* FeedRefreshUpdater} does that)
|
* does that)
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -68,10 +68,7 @@ public class FeedRefreshWorker {
|
|||||||
Duration entriesMaxAge = config.database().cleanup().entriesMaxAge();
|
Duration entriesMaxAge = config.database().cleanup().entriesMaxAge();
|
||||||
if (!entriesMaxAge.isZero()) {
|
if (!entriesMaxAge.isZero()) {
|
||||||
Instant threshold = Instant.now().minus(entriesMaxAge);
|
Instant threshold = Instant.now().minus(entriesMaxAge);
|
||||||
entries =
|
entries = entries.stream().filter(entry -> entry.published().isAfter(threshold)).toList();
|
||||||
entries.stream()
|
|
||||||
.filter(entry -> entry.published().isAfter(threshold))
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String urlAfterRedirect = result.urlAfterRedirect();
|
String urlAfterRedirect = result.urlAfterRedirect();
|
||||||
@@ -121,9 +118,7 @@ public class FeedRefreshWorker {
|
|||||||
|
|
||||||
feed.setErrorCount(feed.getErrorCount() + 1);
|
feed.setErrorCount(feed.getErrorCount() + 1);
|
||||||
feed.setMessage("Server indicated that we are sending too many requests");
|
feed.setMessage("Server indicated that we are sending too many requests");
|
||||||
feed.setDisabledUntil(
|
feed.setDisabledUntil(refreshIntervalCalculator.onTooManyRequests(e.getRetryAfter(), feed.getErrorCount()));
|
||||||
refreshIntervalCalculator.onTooManyRequests(
|
|
||||||
e.getRetryAfter(), feed.getErrorCount()));
|
|
||||||
|
|
||||||
return new FeedRefreshWorkerResult(feed, Collections.emptyList());
|
return new FeedRefreshWorkerResult(feed, Collections.emptyList());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -30,15 +30,12 @@ public class FeedUpdateNotifier {
|
|||||||
|
|
||||||
public void notifyOverWebsocket(FeedSubscription sub, List<FeedEntry> entries) {
|
public void notifyOverWebsocket(FeedSubscription sub, List<FeedEntry> entries) {
|
||||||
if (!entries.isEmpty()) {
|
if (!entries.isEmpty()) {
|
||||||
webSocketSessions.sendMessage(
|
webSocketSessions.sendMessage(sub.getUser(), WebSocketMessageBuilder.newFeedEntries(sub, entries.size()));
|
||||||
sub.getUser(), WebSocketMessageBuilder.newFeedEntries(sub, entries.size()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendPushNotifications(FeedSubscription sub, List<FeedEntry> entries) {
|
public void sendPushNotifications(FeedSubscription sub, List<FeedEntry> entries) {
|
||||||
if (!config.pushNotifications().enabled()
|
if (!config.pushNotifications().enabled() || !sub.isPushNotificationsEnabled() || entries.isEmpty()) {
|
||||||
|| !sub.isPushNotificationsEnabled()
|
|
||||||
|| entries.isEmpty()) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,8 +40,7 @@ public class FeedUtils {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return TextDirectionDetector.detect(stripped)
|
return TextDirectionDetector.detect(stripped) == TextDirectionDetector.Direction.RIGHT_TO_LEFT;
|
||||||
== TextDirectionDetector.Direction.RIGHT_TO_LEFT;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String getFaviconUrl(FeedSubscription subscription) {
|
public static String getFaviconUrl(FeedSubscription subscription) {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import java.nio.charset.Charset;
|
|||||||
public class EncodingDetector {
|
public class EncodingDetector {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect feed encoding by using the declared encoding in the xml processing instruction and by
|
* Detect feed encoding by using the declared encoding in the xml processing instruction and by detecting the
|
||||||
* detecting the characters used in the feed
|
* characters used in the feed
|
||||||
*/
|
*/
|
||||||
public Charset getEncoding(byte[] bytes) {
|
public Charset getEncoding(byte[] bytes) {
|
||||||
String extracted = extractDeclaredEncoding(bytes);
|
String extracted = extractDeclaredEncoding(bytes);
|
||||||
|
|||||||
@@ -42,14 +42,12 @@ import java.util.stream.Collectors;
|
|||||||
@Singleton
|
@Singleton
|
||||||
public class FeedParser {
|
public class FeedParser {
|
||||||
|
|
||||||
private static final Namespace ATOM_10_NS =
|
private static final Namespace ATOM_10_NS = Namespace.getNamespace("http://www.w3.org/2005/Atom");
|
||||||
Namespace.getNamespace("http://www.w3.org/2005/Atom");
|
|
||||||
|
|
||||||
private static final Instant START = Instant.ofEpochMilli(86400000);
|
private static final Instant START = Instant.ofEpochMilli(86400000);
|
||||||
private static final Instant END = Instant.ofEpochMilli(1000L * Integer.MAX_VALUE - 86400000);
|
private static final Instant END = Instant.ofEpochMilli(1000L * Integer.MAX_VALUE - 86400000);
|
||||||
|
|
||||||
private static final Comparator<Entry> ENTRY_COMPARATOR =
|
private static final Comparator<Entry> ENTRY_COMPARATOR = Comparator.comparing(Entry::published).reversed();
|
||||||
Comparator.comparing(Entry::published).reversed();
|
|
||||||
|
|
||||||
private final EncodingDetector encodingDetector;
|
private final EncodingDetector encodingDetector;
|
||||||
private final XMLCleaner xmlCleaner;
|
private final XMLCleaner xmlCleaner;
|
||||||
@@ -78,20 +76,13 @@ public class FeedParser {
|
|||||||
List<Entry> entries = buildEntries(feed, feedUrl);
|
List<Entry> entries = buildEntries(feed, feedUrl);
|
||||||
Instant lastEntryDate = entries.stream().findFirst().map(Entry::published).orElse(null);
|
Instant lastEntryDate = entries.stream().findFirst().map(Entry::published).orElse(null);
|
||||||
Instant lastPublishedDate = toValidInstant(feed.getPublishedDate(), false);
|
Instant lastPublishedDate = toValidInstant(feed.getPublishedDate(), false);
|
||||||
if (lastPublishedDate == null
|
if (lastPublishedDate == null || lastEntryDate != null && lastPublishedDate.isBefore(lastEntryDate)) {
|
||||||
|| lastEntryDate != null && lastPublishedDate.isBefore(lastEntryDate)) {
|
|
||||||
lastPublishedDate = lastEntryDate;
|
lastPublishedDate = lastEntryDate;
|
||||||
}
|
}
|
||||||
Long averageEntryInterval = averageTimeBetweenEntries(entries);
|
Long averageEntryInterval = averageTimeBetweenEntries(entries);
|
||||||
|
|
||||||
return new FeedParserResult(
|
return new FeedParserResult(
|
||||||
title,
|
title, link, iconUrl, lastPublishedDate, averageEntryInterval, lastEntryDate, entries);
|
||||||
link,
|
|
||||||
iconUrl,
|
|
||||||
lastPublishedDate,
|
|
||||||
averageEntryInterval,
|
|
||||||
lastEntryDate,
|
|
||||||
entries);
|
|
||||||
} catch (FeedParsingException e) {
|
} catch (FeedParsingException e) {
|
||||||
throw e;
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -151,9 +142,7 @@ public class FeedParser {
|
|||||||
String author = StringUtils.trimToNull(item.getAuthor());
|
String author = StringUtils.trimToNull(item.getAuthor());
|
||||||
String categories =
|
String categories =
|
||||||
StringUtils.trimToNull(
|
StringUtils.trimToNull(
|
||||||
item.getCategories().stream()
|
item.getCategories().stream().map(SyndCategory::getName).collect(Collectors.joining(", ")));
|
||||||
.map(SyndCategory::getName)
|
|
||||||
.collect(Collectors.joining(", ")));
|
|
||||||
|
|
||||||
Enclosure enclosure = buildEnclosure(item);
|
Enclosure enclosure = buildEnclosure(item);
|
||||||
Media media = buildMedia(item);
|
Media media = buildMedia(item);
|
||||||
@@ -281,9 +270,7 @@ public class FeedParser {
|
|||||||
SummaryStatistics stats = new SummaryStatistics();
|
SummaryStatistics stats = new SummaryStatistics();
|
||||||
for (int i = 0; i < entries.size() - 1; i++) {
|
for (int i = 0; i < entries.size() - 1; i++) {
|
||||||
long diff =
|
long diff =
|
||||||
Math.abs(
|
Math.abs(entries.get(i).published().toEpochMilli() - entries.get(i + 1).published().toEpochMilli());
|
||||||
entries.get(i).published().toEpochMilli()
|
|
||||||
- entries.get(i + 1).published().toEpochMilli());
|
|
||||||
stats.addValue(diff);
|
stats.addValue(diff);
|
||||||
}
|
}
|
||||||
return (long) stats.getMean();
|
return (long) stats.getMean();
|
||||||
|
|||||||
@@ -14,18 +14,9 @@ public record FeedParserResult(
|
|||||||
public record Entry(String guid, String url, Instant published, Content content) {}
|
public record Entry(String guid, String url, Instant published, Content content) {}
|
||||||
|
|
||||||
public record Content(
|
public record Content(
|
||||||
String title,
|
String title, String content, String author, String categories, Enclosure enclosure, Media media) {}
|
||||||
String content,
|
|
||||||
String author,
|
|
||||||
String categories,
|
|
||||||
Enclosure enclosure,
|
|
||||||
Media media) {}
|
|
||||||
|
|
||||||
public record Enclosure(String url, String type) {}
|
public record Enclosure(String url, String type) {}
|
||||||
|
|
||||||
public record Media(
|
public record Media(String description, String thumbnailUrl, Integer thumbnailWidth, Integer thumbnailHeight) {}
|
||||||
String description,
|
|
||||||
String thumbnailUrl,
|
|
||||||
Integer thumbnailWidth,
|
|
||||||
Integer thumbnailHeight) {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,9 @@ import java.util.regex.Pattern;
|
|||||||
@Singleton
|
@Singleton
|
||||||
public class XMLCleaner {
|
public class XMLCleaner {
|
||||||
|
|
||||||
private static final Pattern DOCTYPE_PATTERN =
|
private static final Pattern DOCTYPE_PATTERN = Pattern.compile("<!DOCTYPE[^>]*>", Pattern.CASE_INSENSITIVE);
|
||||||
Pattern.compile("<!DOCTYPE[^>]*>", Pattern.CASE_INSENSITIVE);
|
|
||||||
|
|
||||||
private final Trie trie =
|
private final Trie trie = Trie.builder().ignoreOverlaps().addKeywords(HtmlEntities.HTML_ENTITIES).build();
|
||||||
Trie.builder().ignoreOverlaps().addKeywords(HtmlEntities.HTML_ENTITIES).build();
|
|
||||||
|
|
||||||
public String clean(String xml) {
|
public String clean(String xml) {
|
||||||
xml = removeCharactersBeforeFirstXmlTag(xml);
|
xml = removeCharactersBeforeFirstXmlTag(xml);
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ public class User extends AbstractModel {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private boolean disabled;
|
private boolean disabled;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private int sessionVersion;
|
||||||
|
|
||||||
@Column private Instant lastLogin;
|
@Column private Instant lastLogin;
|
||||||
|
|
||||||
@Column private Instant created;
|
@Column private Instant created;
|
||||||
|
|||||||
@@ -155,8 +155,7 @@ public class UserSettings extends AbstractModel {
|
|||||||
private boolean instapaper;
|
private boolean instapaper;
|
||||||
private boolean buffer;
|
private boolean buffer;
|
||||||
|
|
||||||
@Embedded
|
@Embedded private PushNotificationUserSettings pushNotifications = new PushNotificationUserSettings();
|
||||||
private PushNotificationUserSettings pushNotifications = new PushNotificationUserSettings();
|
|
||||||
|
|
||||||
@Embeddable
|
@Embeddable
|
||||||
@SuppressWarnings("serial")
|
@SuppressWarnings("serial")
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ public class OPMLExporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// export root subscriptions
|
// export root subscriptions
|
||||||
for (FeedSubscription sub :
|
for (FeedSubscription sub : subscriptions.stream().filter(s -> s.getCategory() == null).toList()) {
|
||||||
subscriptions.stream().filter(s -> s.getCategory() == null).toList()) {
|
|
||||||
opml.getOutlines().add(buildSubscriptionOutline(sub));
|
opml.getOutlines().add(buildSubscriptionOutline(sub));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,17 +64,14 @@ public class OPMLExporter {
|
|||||||
|
|
||||||
for (FeedCategory child :
|
for (FeedCategory child :
|
||||||
categories.stream()
|
categories.stream()
|
||||||
.filter(c ->
|
.filter(c -> c.getParent() != null && c.getParent().getId().equals(cat.getId()))
|
||||||
c.getParent() != null && c.getParent().getId().equals(cat.getId()))
|
|
||||||
.toList()) {
|
.toList()) {
|
||||||
outline.getChildren().add(buildCategoryOutline(child, categories, subscriptions));
|
outline.getChildren().add(buildCategoryOutline(child, categories, subscriptions));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (FeedSubscription sub :
|
for (FeedSubscription sub :
|
||||||
subscriptions.stream()
|
subscriptions.stream()
|
||||||
.filter(s ->
|
.filter(s -> s.getCategory() != null && s.getCategory().getId().equals(cat.getId()))
|
||||||
s.getCategory() != null
|
|
||||||
&& s.getCategory().getId().equals(cat.getId()))
|
|
||||||
.toList()) {
|
.toList()) {
|
||||||
outline.getChildren().add(buildSubscriptionOutline(sub));
|
outline.getChildren().add(buildSubscriptionOutline(sub));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,8 +79,7 @@ public class OPMLImporter {
|
|||||||
}
|
}
|
||||||
// make sure we continue with the import process even if a feed failed
|
// make sure we continue with the import process even if a feed failed
|
||||||
try {
|
try {
|
||||||
feedSubscriptionService.subscribe(
|
feedSubscriptionService.subscribe(user, outline.getXmlUrl(), name, parent, position);
|
||||||
user, outline.getXmlUrl(), name, parent, position);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("error while importing {}: {}", outline.getXmlUrl(), e.getMessage());
|
log.error("error while importing {}: {}", outline.getXmlUrl(), e.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,8 @@ import java.util.List;
|
|||||||
public class FeedEntryContentCleaningService {
|
public class FeedEntryContentCleaningService {
|
||||||
|
|
||||||
private static final Safelist HTML_WHITELIST = buildWhiteList();
|
private static final Safelist HTML_WHITELIST = buildWhiteList();
|
||||||
private static final List<String> ALLOWED_IFRAME_CSS_RULES =
|
private static final List<String> ALLOWED_IFRAME_CSS_RULES = Arrays.asList("height", "width", "border");
|
||||||
Arrays.asList("height", "width", "border");
|
private static final List<String> ALLOWED_IMG_CSS_RULES = Arrays.asList("display", "width", "height");
|
||||||
private static final List<String> ALLOWED_IMG_CSS_RULES =
|
|
||||||
Arrays.asList("display", "width", "height");
|
|
||||||
private static final char[] FORBIDDEN_CSS_RULE_CHARACTERS = new char[] {'(', ')'};
|
private static final char[] FORBIDDEN_CSS_RULE_CHARACTERS = new char[] {'(', ')'};
|
||||||
|
|
||||||
public String clean(String content, String baseUri, boolean keepTextOnly) {
|
public String clean(String content, String baseUri, boolean keepTextOnly) {
|
||||||
@@ -59,8 +57,7 @@ public class FeedEntryContentCleaningService {
|
|||||||
e.attr("style", escaped);
|
e.attr("style", escaped);
|
||||||
}
|
}
|
||||||
|
|
||||||
clean.outputSettings(
|
clean.outputSettings(new OutputSettings().escapeMode(EscapeMode.base).prettyPrint(false));
|
||||||
new OutputSettings().escapeMode(EscapeMode.base).prettyPrint(false));
|
|
||||||
Element body = clean.body();
|
Element body = clean.body();
|
||||||
if (keepTextOnly) {
|
if (keepTextOnly) {
|
||||||
content = body.text();
|
content = body.text();
|
||||||
@@ -126,24 +123,13 @@ public class FeedEntryContentCleaningService {
|
|||||||
whitelist.addAttributes("blockquote", "cite");
|
whitelist.addAttributes("blockquote", "cite");
|
||||||
whitelist.addAttributes("col", "span", "width");
|
whitelist.addAttributes("col", "span", "width");
|
||||||
whitelist.addAttributes("colgroup", "span", "width");
|
whitelist.addAttributes("colgroup", "span", "width");
|
||||||
whitelist.addAttributes(
|
whitelist.addAttributes("iframe", "src", "height", "width", "allowfullscreen", "frameborder", "style");
|
||||||
"iframe", "src", "height", "width", "allowfullscreen", "frameborder", "style");
|
|
||||||
whitelist.addAttributes("img", "align", "alt", "height", "src", "title", "width", "style");
|
whitelist.addAttributes("img", "align", "alt", "height", "src", "title", "width", "style");
|
||||||
whitelist.addAttributes("ol", "start", "type");
|
whitelist.addAttributes("ol", "start", "type");
|
||||||
whitelist.addAttributes("q", "cite");
|
whitelist.addAttributes("q", "cite");
|
||||||
whitelist.addAttributes("table", "border", "bordercolor", "summary", "width");
|
whitelist.addAttributes("table", "border", "bordercolor", "summary", "width");
|
||||||
whitelist.addAttributes(
|
whitelist.addAttributes("td", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "width");
|
||||||
"td", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "width");
|
whitelist.addAttributes("th", "border", "bordercolor", "abbr", "axis", "colspan", "rowspan", "scope", "width");
|
||||||
whitelist.addAttributes(
|
|
||||||
"th",
|
|
||||||
"border",
|
|
||||||
"bordercolor",
|
|
||||||
"abbr",
|
|
||||||
"axis",
|
|
||||||
"colspan",
|
|
||||||
"rowspan",
|
|
||||||
"scope",
|
|
||||||
"width");
|
|
||||||
whitelist.addAttributes("ul", "type");
|
whitelist.addAttributes("ul", "type");
|
||||||
|
|
||||||
whitelist.addProtocols("a", "href", "ftp", "http", "https", "magnet", "mailto");
|
whitelist.addProtocols("a", "href", "ftp", "http", "https", "magnet", "mailto");
|
||||||
@@ -161,8 +147,7 @@ public class FeedEntryContentCleaningService {
|
|||||||
String rule = "";
|
String rule = "";
|
||||||
try {
|
try {
|
||||||
List<String> rules = new ArrayList<>();
|
List<String> rules = new ArrayList<>();
|
||||||
CSSStyleDeclaration decl =
|
CSSStyleDeclaration decl = buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
|
||||||
buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
|
|
||||||
|
|
||||||
for (int i = 0; i < decl.getLength(); i++) {
|
for (int i = 0; i < decl.getLength(); i++) {
|
||||||
String property = decl.item(i);
|
String property = decl.item(i);
|
||||||
@@ -187,8 +172,7 @@ public class FeedEntryContentCleaningService {
|
|||||||
String rule = "";
|
String rule = "";
|
||||||
try {
|
try {
|
||||||
List<String> rules = new ArrayList<>();
|
List<String> rules = new ArrayList<>();
|
||||||
CSSStyleDeclaration decl =
|
CSSStyleDeclaration decl = buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
|
||||||
buildCssParser().parseStyleDeclaration(new InputSource(new StringReader(orig)));
|
|
||||||
|
|
||||||
for (int i = 0; i < decl.getLength(); i++) {
|
for (int i = 0; i < decl.getLength(); i++) {
|
||||||
String property = decl.item(i);
|
String property = decl.item(i);
|
||||||
@@ -216,8 +200,7 @@ public class FeedEntryContentCleaningService {
|
|||||||
new ErrorHandler() {
|
new ErrorHandler() {
|
||||||
@Override
|
@Override
|
||||||
public void warning(CSSParseException exception) throws CSSException {
|
public void warning(CSSParseException exception) throws CSSException {
|
||||||
log.debug(
|
log.debug("warning while parsing css: {}", exception.getMessage(), exception);
|
||||||
"warning while parsing css: {}", exception.getMessage(), exception);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -227,10 +210,7 @@ public class FeedEntryContentCleaningService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void fatalError(CSSParseException exception) throws CSSException {
|
public void fatalError(CSSParseException exception) throws CSSException {
|
||||||
log.debug(
|
log.debug("fatal error while parsing css: {}", exception.getMessage(), exception);
|
||||||
"fatal error while parsing css: {}",
|
|
||||||
exception.getMessage(),
|
|
||||||
exception);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ public class FeedEntryContentService {
|
|||||||
public FeedEntryContent findOrCreate(Content content, String baseUrl) {
|
public FeedEntryContent findOrCreate(Content content, String baseUrl) {
|
||||||
FeedEntryContent entryContent = buildContent(content, baseUrl);
|
FeedEntryContent entryContent = buildContent(content, baseUrl);
|
||||||
Optional<FeedEntryContent> existing =
|
Optional<FeedEntryContent> existing =
|
||||||
feedEntryContentDAO
|
feedEntryContentDAO.findExisting(entryContent.getContentHash(), entryContent.getTitleHash()).stream()
|
||||||
.findExisting(entryContent.getContentHash(), entryContent.getTitleHash())
|
|
||||||
.stream()
|
|
||||||
.filter(entryContent::equivalentTo)
|
.filter(entryContent::equivalentTo)
|
||||||
.findFirst();
|
.findFirst();
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
@@ -44,11 +42,9 @@ public class FeedEntryContentService {
|
|||||||
FeedEntryContent entryContent = new FeedEntryContent();
|
FeedEntryContent entryContent = new FeedEntryContent();
|
||||||
entryContent.setTitleHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.title())));
|
entryContent.setTitleHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.title())));
|
||||||
entryContent.setContentHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.content())));
|
entryContent.setContentHash(Digests.sha1Hex(StringUtils.trimToEmpty(content.content())));
|
||||||
entryContent.setTitle(
|
entryContent.setTitle(FeedUtils.truncate(cleaningService.clean(content.title(), baseUrl, true), 2048));
|
||||||
FeedUtils.truncate(cleaningService.clean(content.title(), baseUrl, true), 2048));
|
|
||||||
entryContent.setContent(cleaningService.clean(content.content(), baseUrl, false));
|
entryContent.setContent(cleaningService.clean(content.content(), baseUrl, false));
|
||||||
entryContent.setAuthor(
|
entryContent.setAuthor(FeedUtils.truncate(cleaningService.clean(content.author(), baseUrl, true), 128));
|
||||||
FeedUtils.truncate(cleaningService.clean(content.author(), baseUrl, true), 128));
|
|
||||||
entryContent.setCategories(FeedUtils.truncate(content.categories(), 4096));
|
entryContent.setCategories(FeedUtils.truncate(content.categories(), 4096));
|
||||||
entryContent.setDirection(
|
entryContent.setDirection(
|
||||||
FeedUtils.isRTL(content.title(), content.content())
|
FeedUtils.isRTL(content.title(), content.content())
|
||||||
@@ -63,8 +59,7 @@ public class FeedEntryContentService {
|
|||||||
|
|
||||||
Media media = content.media();
|
Media media = content.media();
|
||||||
if (media != null) {
|
if (media != null) {
|
||||||
entryContent.setMediaDescription(
|
entryContent.setMediaDescription(cleaningService.clean(media.description(), baseUrl, false));
|
||||||
cleaningService.clean(media.description(), baseUrl, false));
|
|
||||||
entryContent.setMediaThumbnailUrl(FeedUtils.truncate(media.thumbnailUrl(), 2048));
|
entryContent.setMediaThumbnailUrl(FeedUtils.truncate(media.thumbnailUrl(), 2048));
|
||||||
entryContent.setMediaThumbnailWidth(media.thumbnailWidth());
|
entryContent.setMediaThumbnailWidth(media.thumbnailWidth());
|
||||||
entryContent.setMediaThumbnailHeight(media.thumbnailHeight());
|
entryContent.setMediaThumbnailHeight(media.thumbnailHeight());
|
||||||
|
|||||||
@@ -50,27 +50,17 @@ public class FeedEntryFilteringService {
|
|||||||
private final ExecutorService executor = Executors.newCachedThreadPool();
|
private final ExecutorService executor = Executors.newCachedThreadPool();
|
||||||
private final CommaFeedConfiguration config;
|
private final CommaFeedConfiguration config;
|
||||||
|
|
||||||
public boolean filterMatchesEntry(String filter, FeedEntry entry)
|
public boolean filterMatchesEntry(String filter, FeedEntry entry) throws FeedEntryFilterException {
|
||||||
throws FeedEntryFilterException {
|
|
||||||
if (StringUtils.isBlank(filter)) {
|
if (StringUtils.isBlank(filter)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
String title =
|
String title = entry.getContent().getTitle() == null ? "" : Jsoup.parse(entry.getContent().getTitle()).text();
|
||||||
entry.getContent().getTitle() == null
|
String author = entry.getContent().getAuthor() == null ? "" : entry.getContent().getAuthor();
|
||||||
? ""
|
|
||||||
: Jsoup.parse(entry.getContent().getTitle()).text();
|
|
||||||
String author =
|
|
||||||
entry.getContent().getAuthor() == null ? "" : entry.getContent().getAuthor();
|
|
||||||
String content =
|
String content =
|
||||||
entry.getContent().getContent() == null
|
entry.getContent().getContent() == null ? "" : Jsoup.parse(entry.getContent().getContent()).text();
|
||||||
? ""
|
|
||||||
: Jsoup.parse(entry.getContent().getContent()).text();
|
|
||||||
String url = entry.getUrl() == null ? "" : entry.getUrl();
|
String url = entry.getUrl() == null ? "" : entry.getUrl();
|
||||||
String categories =
|
String categories = entry.getContent().getCategories() == null ? "" : entry.getContent().getCategories();
|
||||||
entry.getContent().getCategories() == null
|
|
||||||
? ""
|
|
||||||
: entry.getContent().getCategories();
|
|
||||||
|
|
||||||
Map<String, Object> data = new HashMap<>();
|
Map<String, Object> data = new HashMap<>();
|
||||||
data.put("title", title);
|
data.put("title", title);
|
||||||
@@ -97,11 +87,9 @@ public class FeedEntryFilteringService {
|
|||||||
TimeUnit.MILLISECONDS);
|
TimeUnit.MILLISECONDS);
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
throw new FeedEntryFilterException(
|
throw new FeedEntryFilterException("interrupted while evaluating expression " + filter, e);
|
||||||
"interrupted while evaluating expression " + filter, e);
|
|
||||||
} catch (ExecutionException e) {
|
} catch (ExecutionException e) {
|
||||||
throw new FeedEntryFilterException(
|
throw new FeedEntryFilterException("Exception while evaluating expression " + filter, e);
|
||||||
"Exception while evaluating expression " + filter, e);
|
|
||||||
} catch (TimeoutException e) {
|
} catch (TimeoutException e) {
|
||||||
throw new FeedEntryFilterException("Took too long evaluating expression " + filter, e);
|
throw new FeedEntryFilterException("Took too long evaluating expression " + filter, e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,12 +55,9 @@ public class FeedEntryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<Entry> removeExistingEntries(Feed feed, List<Entry> entries) {
|
public List<Entry> removeExistingEntries(Feed feed, List<Entry> entries) {
|
||||||
Set<String> guidHashes =
|
Set<String> guidHashes = entries.stream().map(e -> Digests.sha1Hex(e.guid())).collect(Collectors.toSet());
|
||||||
entries.stream().map(e -> Digests.sha1Hex(e.guid())).collect(Collectors.toSet());
|
|
||||||
Set<String> existingGuidHashes = feedEntryDAO.findExistingGuidHashes(guidHashes, feed);
|
Set<String> existingGuidHashes = feedEntryDAO.findExistingGuidHashes(guidHashes, feed);
|
||||||
return entries.stream()
|
return entries.stream().filter(e -> !existingGuidHashes.contains(Digests.sha1Hex(e.guid()))).toList();
|
||||||
.filter(e -> !existingGuidHashes.contains(Digests.sha1Hex(e.guid())))
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean applyFilter(FeedSubscription sub, FeedEntry entry) {
|
public boolean applyFilter(FeedSubscription sub, FeedEntry entry) {
|
||||||
@@ -122,37 +119,22 @@ public class FeedEntryService {
|
|||||||
List<FeedEntryKeyword> keywords) {
|
List<FeedEntryKeyword> keywords) {
|
||||||
List<FeedEntryStatus> statuses =
|
List<FeedEntryStatus> statuses =
|
||||||
feedEntryStatusDAO.findBySubscriptions(
|
feedEntryStatusDAO.findBySubscriptions(
|
||||||
user,
|
user, subscriptions, true, keywords, null, -1, -1, null, false, null, null, null);
|
||||||
subscriptions,
|
|
||||||
true,
|
|
||||||
keywords,
|
|
||||||
null,
|
|
||||||
-1,
|
|
||||||
-1,
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null);
|
|
||||||
markList(statuses, olderThan, insertedBefore);
|
markList(statuses, olderThan, insertedBefore);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markStarredEntries(User user, Instant olderThan, Instant insertedBefore) {
|
public void markStarredEntries(User user, Instant olderThan, Instant insertedBefore) {
|
||||||
List<FeedEntryStatus> statuses =
|
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findStarred(user, null, null, -1, -1, null, false);
|
||||||
feedEntryStatusDAO.findStarred(user, null, null, -1, -1, null, false);
|
|
||||||
markList(statuses, olderThan, insertedBefore);
|
markList(statuses, olderThan, insertedBefore);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void markList(
|
private void markList(List<FeedEntryStatus> statuses, Instant olderThan, Instant insertedBefore) {
|
||||||
List<FeedEntryStatus> statuses, Instant olderThan, Instant insertedBefore) {
|
|
||||||
List<FeedEntryStatus> statusesToMark =
|
List<FeedEntryStatus> statusesToMark =
|
||||||
statuses.stream()
|
statuses.stream()
|
||||||
.filter(FeedEntryStatus::isMarkable)
|
.filter(FeedEntryStatus::isMarkable)
|
||||||
.filter(s -> {
|
.filter(s -> {
|
||||||
Instant entryDate = s.getEntry().getPublished();
|
Instant entryDate = s.getEntry().getPublished();
|
||||||
return olderThan == null
|
return olderThan == null || entryDate == null || entryDate.isBefore(olderThan);
|
||||||
|| entryDate == null
|
|
||||||
|| entryDate.isBefore(olderThan);
|
|
||||||
})
|
})
|
||||||
.filter(s -> {
|
.filter(s -> {
|
||||||
Instant insertedDate = s.getEntry().getInserted();
|
Instant insertedDate = s.getEntry().getInserted();
|
||||||
|
|||||||
@@ -36,16 +36,14 @@ public class FeedEntryTagService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<FeedEntryTag> existingTags = feedEntryTagDAO.findByEntry(user, entry);
|
List<FeedEntryTag> existingTags = feedEntryTagDAO.findByEntry(user, entry);
|
||||||
Set<String> existingTagNames =
|
Set<String> existingTagNames = existingTags.stream().map(FeedEntryTag::getName).collect(Collectors.toSet());
|
||||||
existingTags.stream().map(FeedEntryTag::getName).collect(Collectors.toSet());
|
|
||||||
|
|
||||||
List<FeedEntryTag> addList =
|
List<FeedEntryTag> addList =
|
||||||
tagNames.stream()
|
tagNames.stream()
|
||||||
.filter(name -> !existingTagNames.contains(name))
|
.filter(name -> !existingTagNames.contains(name))
|
||||||
.map(name -> new FeedEntryTag(user, entry, name))
|
.map(name -> new FeedEntryTag(user, entry, name))
|
||||||
.toList();
|
.toList();
|
||||||
List<FeedEntryTag> removeList =
|
List<FeedEntryTag> removeList = existingTags.stream().filter(tag -> !tagNames.contains(tag.getName())).toList();
|
||||||
existingTags.stream().filter(tag -> !tagNames.contains(tag.getName())).toList();
|
|
||||||
|
|
||||||
addList.forEach(feedEntryTagDAO::persist);
|
addList.forEach(feedEntryTagDAO::persist);
|
||||||
feedEntryTagDAO.delete(removeList);
|
feedEntryTagDAO.delete(removeList);
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ public class FeedFaviconService {
|
|||||||
this.defaultFavicon =
|
this.defaultFavicon =
|
||||||
new Favicon(
|
new Favicon(
|
||||||
Resources.toByteArray(
|
Resources.toByteArray(
|
||||||
Objects.requireNonNull(
|
Objects.requireNonNull(getClass().getResource("/images/default_favicon.gif"))),
|
||||||
getClass().getResource("/images/default_favicon.gif"))),
|
|
||||||
"image/gif");
|
"image/gif");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,15 +48,13 @@ public class FeedSubscriptionService {
|
|||||||
// by the feed engine
|
// by the feed engine
|
||||||
feedSubscriptionDAO.onPostCommitInsert(sub -> {
|
feedSubscriptionDAO.onPostCommitInsert(sub -> {
|
||||||
Feed feed = sub.getFeed();
|
Feed feed = sub.getFeed();
|
||||||
if (feed.getDisabledUntil() == null
|
if (feed.getDisabledUntil() == null || feed.getDisabledUntil().isBefore(Instant.now())) {
|
||||||
|| feed.getDisabledUntil().isBefore(Instant.now())) {
|
|
||||||
feedRefreshEngine.refreshImmediately(feed);
|
feedRefreshEngine.refreshImmediately(feed);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public long subscribe(
|
public long subscribe(User user, String url, String title, FeedCategory category, int position) {
|
||||||
User user, String url, String title, FeedCategory category, int position) {
|
|
||||||
Integer maxFeedsPerUser = config.database().cleanup().maxFeedsPerUser();
|
Integer maxFeedsPerUser = config.database().cleanup().maxFeedsPerUser();
|
||||||
if (maxFeedsPerUser > 0 && feedSubscriptionDAO.count(user) >= maxFeedsPerUser) {
|
if (maxFeedsPerUser > 0 && feedSubscriptionDAO.count(user) >= maxFeedsPerUser) {
|
||||||
String message =
|
String message =
|
||||||
@@ -98,9 +96,7 @@ public class FeedSubscriptionService {
|
|||||||
public void refreshAll(User user) throws ForceFeedRefreshTooSoonException {
|
public void refreshAll(User user) throws ForceFeedRefreshTooSoonException {
|
||||||
Instant lastForceRefresh = user.getLastForceRefresh();
|
Instant lastForceRefresh = user.getLastForceRefresh();
|
||||||
if (lastForceRefresh != null
|
if (lastForceRefresh != null
|
||||||
&& lastForceRefresh
|
&& lastForceRefresh.plus(config.feedRefresh().forceRefreshCooldownDuration()).isAfter(Instant.now())) {
|
||||||
.plus(config.feedRefresh().forceRefreshCooldownDuration())
|
|
||||||
.isAfter(Instant.now())) {
|
|
||||||
throw new ForceFeedRefreshTooSoonException();
|
throw new ForceFeedRefreshTooSoonException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,16 +47,13 @@ public class PushNotificationService {
|
|||||||
private final CommaFeedConfiguration config;
|
private final CommaFeedConfiguration config;
|
||||||
|
|
||||||
public PushNotificationService(
|
public PushNotificationService(
|
||||||
HttpClientFactory httpClientFactory,
|
HttpClientFactory httpClientFactory, MetricRegistry metrics, CommaFeedConfiguration config) {
|
||||||
MetricRegistry metrics,
|
|
||||||
CommaFeedConfiguration config) {
|
|
||||||
this.httpClient = httpClientFactory.newClient(config.pushNotifications().threads());
|
this.httpClient = httpClientFactory.newClient(config.pushNotifications().threads());
|
||||||
this.meter = metrics.meter(MetricRegistry.name(getClass(), "notify"));
|
this.meter = metrics.meter(MetricRegistry.name(getClass(), "notify"));
|
||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void notify(
|
public void notify(PushNotificationUserSettings settings, FeedSubscription subscription, FeedEntry entry) {
|
||||||
PushNotificationUserSettings settings, FeedSubscription subscription, FeedEntry entry) {
|
|
||||||
if (!config.pushNotifications().enabled() || settings.getType() == null) {
|
if (!config.pushNotifications().enabled() || settings.getType() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -79,9 +76,7 @@ public class PushNotificationService {
|
|||||||
case NTFY -> sendNtfy(settings, feedTitle, entryTitle, entryUrl);
|
case NTFY -> sendNtfy(settings, feedTitle, entryTitle, entryUrl);
|
||||||
case GOTIFY -> sendGotify(settings, feedTitle, entryTitle, entryUrl);
|
case GOTIFY -> sendGotify(settings, feedTitle, entryTitle, entryUrl);
|
||||||
case PUSHOVER -> sendPushover(settings, feedTitle, entryTitle, entryUrl);
|
case PUSHOVER -> sendPushover(settings, feedTitle, entryTitle, entryUrl);
|
||||||
default ->
|
default -> throw new IllegalStateException("unsupported notification type: " + settings.getType());
|
||||||
throw new IllegalStateException(
|
|
||||||
"unsupported notification type: " + settings.getType());
|
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new PushNotificationException("Failed to send external notification", e);
|
throw new PushNotificationException("Failed to send external notification", e);
|
||||||
@@ -90,11 +85,7 @@ public class PushNotificationService {
|
|||||||
meter.mark();
|
meter.mark();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendNtfy(
|
private void sendNtfy(PushNotificationUserSettings settings, String feedTitle, String entryTitle, String entryUrl)
|
||||||
PushNotificationUserSettings settings,
|
|
||||||
String feedTitle,
|
|
||||||
String entryTitle,
|
|
||||||
String entryUrl)
|
|
||||||
throws IOException {
|
throws IOException {
|
||||||
String serverUrl = Urls.removeTrailingSlash(settings.getServerUrl());
|
String serverUrl = Urls.removeTrailingSlash(settings.getServerUrl());
|
||||||
String topic = settings.getTopic();
|
String topic = settings.getTopic();
|
||||||
@@ -119,11 +110,7 @@ public class PushNotificationService {
|
|||||||
send("ntfy", request.build());
|
send("ntfy", request.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendGotify(
|
private void sendGotify(PushNotificationUserSettings settings, String feedTitle, String entryTitle, String entryUrl)
|
||||||
PushNotificationUserSettings settings,
|
|
||||||
String feedTitle,
|
|
||||||
String entryTitle,
|
|
||||||
String entryUrl)
|
|
||||||
throws IOException {
|
throws IOException {
|
||||||
String serverUrl = Urls.removeTrailingSlash(settings.getServerUrl());
|
String serverUrl = Urls.removeTrailingSlash(settings.getServerUrl());
|
||||||
String token = settings.getUserSecret();
|
String token = settings.getUserSecret();
|
||||||
@@ -143,8 +130,7 @@ public class PushNotificationService {
|
|||||||
new JsonObject()
|
new JsonObject()
|
||||||
.put(
|
.put(
|
||||||
"client::notification",
|
"client::notification",
|
||||||
new JsonObject()
|
new JsonObject().put("click", new JsonObject().put("url", entryUrl))));
|
||||||
.put("click", new JsonObject().put("url", entryUrl))));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SimpleRequestBuilder request = SimpleRequestBuilder.post(serverUrl + "/message");
|
SimpleRequestBuilder request = SimpleRequestBuilder.post(serverUrl + "/message");
|
||||||
@@ -155,10 +141,7 @@ public class PushNotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void sendPushover(
|
private void sendPushover(
|
||||||
PushNotificationUserSettings settings,
|
PushNotificationUserSettings settings, String feedTitle, String entryTitle, String entryUrl)
|
||||||
String feedTitle,
|
|
||||||
String entryTitle,
|
|
||||||
String entryUrl)
|
|
||||||
throws IOException {
|
throws IOException {
|
||||||
String token = settings.getUserSecret();
|
String token = settings.getUserSecret();
|
||||||
String userKey = settings.getUserId();
|
String userKey = settings.getUserId();
|
||||||
@@ -177,8 +160,7 @@ public class PushNotificationService {
|
|||||||
params.add(new BasicNameValuePair("url", entryUrl));
|
params.add(new BasicNameValuePair("url", entryUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
SimpleRequestBuilder request =
|
SimpleRequestBuilder request = SimpleRequestBuilder.post("https://api.pushover.net/1/messages.json");
|
||||||
SimpleRequestBuilder.post("https://api.pushover.net/1/messages.json");
|
|
||||||
request.setBody(
|
request.setBody(
|
||||||
WWWFormCodec.format(params, StandardCharsets.UTF_8),
|
WWWFormCodec.format(params, StandardCharsets.UTF_8),
|
||||||
ContentType.APPLICATION_FORM_URLENCODED.withCharset(StandardCharsets.UTF_8));
|
ContentType.APPLICATION_FORM_URLENCODED.withCharset(StandardCharsets.UTF_8));
|
||||||
@@ -194,11 +176,9 @@ public class PushNotificationService {
|
|||||||
null);
|
null);
|
||||||
try {
|
try {
|
||||||
HttpResponse response =
|
HttpResponse response =
|
||||||
future.get(config.httpClient().responseTimeout().toSeconds(), TimeUnit.SECONDS)
|
future.get(config.httpClient().responseTimeout().toSeconds(), TimeUnit.SECONDS).getHead();
|
||||||
.getHead();
|
|
||||||
if (response.getCode() >= 400) {
|
if (response.getCode() >= 400) {
|
||||||
throw new PushNotificationException(
|
throw new PushNotificationException(name + " notification failed with status " + response.getCode());
|
||||||
name + " notification failed with status " + response.getCode());
|
|
||||||
}
|
}
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
future.cancel(true);
|
future.cancel(true);
|
||||||
|
|||||||
@@ -53,8 +53,7 @@ public class UserService {
|
|||||||
user = userDAO.findByEmail(nameOrEmail);
|
user = userDAO.findByEmail(nameOrEmail);
|
||||||
}
|
}
|
||||||
if (user != null && !user.isDisabled()) {
|
if (user != null && !user.isDisabled()) {
|
||||||
boolean authenticated =
|
boolean authenticated = encryptionService.authenticate(password, user.getPassword(), user.getSalt());
|
||||||
encryptionService.authenticate(password, user.getPassword(), user.getSalt());
|
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
performPostLoginActivities(user);
|
performPostLoginActivities(user);
|
||||||
return Optional.of(user);
|
return Optional.of(user);
|
||||||
@@ -107,16 +106,11 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public User register(
|
public User register(
|
||||||
String name,
|
String name, String password, String email, Collection<Role> roles, boolean forceRegistration) {
|
||||||
String password,
|
|
||||||
String email,
|
|
||||||
Collection<Role> roles,
|
|
||||||
boolean forceRegistration) {
|
|
||||||
|
|
||||||
if (!forceRegistration) {
|
if (!forceRegistration) {
|
||||||
Preconditions.checkState(
|
Preconditions.checkState(
|
||||||
config.users().allowRegistrations(),
|
config.users().allowRegistrations(), "Registrations are closed on this CommaFeed instance");
|
||||||
"Registrations are closed on this CommaFeed instance");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Preconditions.checkArgument(userDAO.findByName(name) == null, "Name already taken");
|
Preconditions.checkArgument(userDAO.findByName(name) == null, "Name already taken");
|
||||||
@@ -155,10 +149,19 @@ public class UserService {
|
|||||||
userDAO.delete(user);
|
userDAO.delete(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void changePassword(User user, String password) {
|
||||||
|
user.setPassword(encryptionService.getEncryptedPassword(password, user.getSalt()));
|
||||||
|
userDAO.merge(user);
|
||||||
|
|
||||||
|
invalidateSessions(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void invalidateSessions(User user) {
|
||||||
|
user.setSessionVersion(user.getSessionVersion() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
public String generateApiKey(User user) {
|
public String generateApiKey(User user) {
|
||||||
byte[] key =
|
byte[] key = encryptionService.getEncryptedPassword(UUID.randomUUID().toString(), user.getSalt());
|
||||||
encryptionService.getEncryptedPassword(
|
|
||||||
UUID.randomUUID().toString(), user.getSalt());
|
|
||||||
return Digests.sha1Hex(key);
|
return Digests.sha1Hex(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,18 +71,15 @@ public class DatabaseCleaningService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
entriesDeleted =
|
entriesDeleted = unitOfWork.call(() -> feedEntryDAO.delete(feed.getId(), batchSize));
|
||||||
unitOfWork.call(() -> feedEntryDAO.delete(feed.getId(), batchSize));
|
|
||||||
entriesDeletedMeter.mark(entriesDeleted);
|
entriesDeletedMeter.mark(entriesDeleted);
|
||||||
entriesTotal += entriesDeleted;
|
entriesTotal += entriesDeleted;
|
||||||
log.debug("removed {} entries for feeds without subscriptions", entriesTotal);
|
log.debug("removed {} entries for feeds without subscriptions", entriesTotal);
|
||||||
} while (entriesDeleted > 0);
|
} while (entriesDeleted > 0);
|
||||||
}
|
}
|
||||||
deleted =
|
deleted =
|
||||||
unitOfWork.call(() ->
|
unitOfWork.call(
|
||||||
feedDAO.delete(
|
() -> feedDAO.delete(feedDAO.findByIds(feeds.stream().map(AbstractModel::getId).toList())));
|
||||||
feedDAO.findByIds(
|
|
||||||
feeds.stream().map(AbstractModel::getId).toList())));
|
|
||||||
total += deleted;
|
total += deleted;
|
||||||
log.debug("removed {} feeds without subscriptions", total);
|
log.debug("removed {} feeds without subscriptions", total);
|
||||||
} while (deleted != 0);
|
} while (deleted != 0);
|
||||||
@@ -117,8 +114,7 @@ public class DatabaseCleaningService {
|
|||||||
|
|
||||||
List<FeedCapacity> feeds =
|
List<FeedCapacity> feeds =
|
||||||
unitOfWork.call(() ->
|
unitOfWork.call(() ->
|
||||||
feedEntryDAO.findFeedsExceedingCapacity(
|
feedEntryDAO.findFeedsExceedingCapacity(maxFeedCapacity, batchSize, keepStarredEntries));
|
||||||
maxFeedCapacity, batchSize, keepStarredEntries));
|
|
||||||
if (feeds.isEmpty()) {
|
if (feeds.isEmpty()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -128,8 +124,7 @@ public class DatabaseCleaningService {
|
|||||||
int deleted;
|
int deleted;
|
||||||
do {
|
do {
|
||||||
if (Thread.currentThread().isInterrupted()) {
|
if (Thread.currentThread().isInterrupted()) {
|
||||||
log.info(
|
log.info("interrupted, stopping cleanup of entries exceeding feed capacity");
|
||||||
"interrupted, stopping cleanup of entries exceeding feed capacity");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,9 +132,7 @@ public class DatabaseCleaningService {
|
|||||||
deleted =
|
deleted =
|
||||||
unitOfWork.call(() ->
|
unitOfWork.call(() ->
|
||||||
feedEntryDAO.deleteOldEntries(
|
feedEntryDAO.deleteOldEntries(
|
||||||
feed.id(),
|
feed.id(), Math.min(batchSize, rem), keepStarredEntries));
|
||||||
Math.min(batchSize, rem),
|
|
||||||
keepStarredEntries));
|
|
||||||
entriesDeletedMeter.mark(deleted);
|
entriesDeletedMeter.mark(deleted);
|
||||||
total += deleted;
|
total += deleted;
|
||||||
remaining -= deleted;
|
remaining -= deleted;
|
||||||
@@ -161,9 +154,8 @@ public class DatabaseCleaningService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleted =
|
deleted =
|
||||||
unitOfWork.call(() ->
|
unitOfWork.call(
|
||||||
feedEntryDAO.deleteEntriesOlderThan(
|
() -> feedEntryDAO.deleteEntriesOlderThan(olderThan, batchSize, keepStarredEntries));
|
||||||
olderThan, batchSize, keepStarredEntries));
|
|
||||||
entriesDeletedMeter.mark(deleted);
|
entriesDeletedMeter.mark(deleted);
|
||||||
total += deleted;
|
total += deleted;
|
||||||
log.debug("removed {} old entries", total);
|
log.debug("removed {} old entries", total);
|
||||||
@@ -181,9 +173,7 @@ public class DatabaseCleaningService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
deleted =
|
deleted = unitOfWork.call(() -> feedEntryStatusDAO.deleteOldStatuses(olderThan, batchSize));
|
||||||
unitOfWork.call(
|
|
||||||
() -> feedEntryStatusDAO.deleteOldStatuses(olderThan, batchSize));
|
|
||||||
total += deleted;
|
total += deleted;
|
||||||
log.debug("removed {} old read statuses", total);
|
log.debug("removed {} old read statuses", total);
|
||||||
} while (deleted != 0);
|
} while (deleted != 0);
|
||||||
|
|||||||
@@ -27,14 +27,12 @@ public class DatabaseStartupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a postgresql database in liquibase that doesn't escape columns, so that we can use
|
* Register a postgresql database in liquibase that doesn't escape columns, so that we can use lower case columns
|
||||||
* lower case columns
|
|
||||||
*/
|
*/
|
||||||
@MetaInfServices(Database.class)
|
@MetaInfServices(Database.class)
|
||||||
public static class LowerCaseColumnsPostgresDatabase extends PostgresDatabase {
|
public static class LowerCaseColumnsPostgresDatabase extends PostgresDatabase {
|
||||||
@Override
|
@Override
|
||||||
public String escapeObjectName(
|
public String escapeObjectName(String objectName, Class<? extends DatabaseObject> objectType) {
|
||||||
String objectName, Class<? extends DatabaseObject> objectType) {
|
|
||||||
return objectName;
|
return objectName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ import java.util.List;
|
|||||||
public class YoutubeFeedURLProvider implements FeedURLProvider {
|
public class YoutubeFeedURLProvider implements FeedURLProvider {
|
||||||
|
|
||||||
private static final String PREFIX = "https://www.youtube.com/channel/";
|
private static final String PREFIX = "https://www.youtube.com/channel/";
|
||||||
private static final String REPLACEMENT_PREFIX =
|
private static final String REPLACEMENT_PREFIX = "https://www.youtube.com/feeds/videos.xml?channel_id=";
|
||||||
"https://www.youtube.com/feeds/videos.xml?channel_id=";
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<String> get(String url, String urlContent) {
|
public List<String> get(String url, String urlContent) {
|
||||||
|
|||||||
@@ -67,10 +67,7 @@ public class Entry implements Serializable {
|
|||||||
@Schema(description = "entry publication date", type = SchemaType.INTEGER, required = true)
|
@Schema(description = "entry publication date", type = SchemaType.INTEGER, required = true)
|
||||||
private Instant date;
|
private Instant date;
|
||||||
|
|
||||||
@Schema(
|
@Schema(description = "entry insertion date in the database", type = SchemaType.INTEGER, required = true)
|
||||||
description = "entry insertion date in the database",
|
|
||||||
type = SchemaType.INTEGER,
|
|
||||||
required = true)
|
|
||||||
private Instant insertedDate;
|
private Instant insertedDate;
|
||||||
|
|
||||||
@Schema(description = "feed id", required = true)
|
@Schema(description = "feed id", required = true)
|
||||||
@@ -97,9 +94,7 @@ public class Entry implements Serializable {
|
|||||||
@Schema(description = "starred status", required = true)
|
@Schema(description = "starred status", required = true)
|
||||||
private boolean starred;
|
private boolean starred;
|
||||||
|
|
||||||
@Schema(
|
@Schema(description = "whether the entry is still markable (old entry statuses are discarded)", required = true)
|
||||||
description = "whether the entry is still markable (old entry statuses are discarded)",
|
|
||||||
required = true)
|
|
||||||
private boolean markable;
|
private boolean markable;
|
||||||
|
|
||||||
@Schema(description = "tags", required = true)
|
@Schema(description = "tags", required = true)
|
||||||
@@ -130,10 +125,7 @@ public class Entry implements Serializable {
|
|||||||
if (content != null) {
|
if (content != null) {
|
||||||
entry.setRtl(content.getDirection() == FeedEntryContent.Direction.RTL);
|
entry.setRtl(content.getDirection() == FeedEntryContent.Direction.RTL);
|
||||||
entry.setTitle(content.getTitle());
|
entry.setTitle(content.getTitle());
|
||||||
entry.setContent(
|
entry.setContent(proxyImages ? FeedUtils.proxyImages(content.getContent()) : content.getContent());
|
||||||
proxyImages
|
|
||||||
? FeedUtils.proxyImages(content.getContent())
|
|
||||||
: content.getContent());
|
|
||||||
entry.setAuthor(content.getAuthor());
|
entry.setAuthor(content.getAuthor());
|
||||||
|
|
||||||
entry.setEnclosureType(content.getEnclosureType());
|
entry.setEnclosureType(content.getEnclosureType());
|
||||||
|
|||||||
@@ -29,14 +29,10 @@ public class Settings implements Serializable {
|
|||||||
@Schema(description = "user reads entries in ascending or descending order", required = true)
|
@Schema(description = "user reads entries in ascending or descending order", required = true)
|
||||||
private ReadingOrder readingOrder;
|
private ReadingOrder readingOrder;
|
||||||
|
|
||||||
@Schema(
|
@Schema(description = "user wants category and feeds with no unread entries shown", required = true)
|
||||||
description = "user wants category and feeds with no unread entries shown",
|
|
||||||
required = true)
|
|
||||||
private boolean showRead;
|
private boolean showRead;
|
||||||
|
|
||||||
@Schema(
|
@Schema(description = "In expanded view, scroll through entries mark them as read", required = true)
|
||||||
description = "In expanded view, scroll through entries mark them as read",
|
|
||||||
required = true)
|
|
||||||
private boolean scrollMarks;
|
private boolean scrollMarks;
|
||||||
|
|
||||||
@Schema(description = "user's custom css for the website")
|
@Schema(description = "user's custom css for the website")
|
||||||
@@ -45,42 +41,33 @@ public class Settings implements Serializable {
|
|||||||
@Schema(description = "user's custom js for the website")
|
@Schema(description = "user's custom js for the website")
|
||||||
private String customJs;
|
private String customJs;
|
||||||
|
|
||||||
@Schema(
|
@Schema(description = "user's preferred scroll speed when navigating between entries", required = true)
|
||||||
description = "user's preferred scroll speed when navigating between entries",
|
|
||||||
required = true)
|
|
||||||
private int scrollSpeed;
|
private int scrollSpeed;
|
||||||
|
|
||||||
@Schema(description = "whether to scroll to the selected entry", required = true)
|
@Schema(description = "whether to scroll to the selected entry", required = true)
|
||||||
private ScrollMode scrollMode;
|
private ScrollMode scrollMode;
|
||||||
|
|
||||||
@Schema(
|
@Schema(description = "number of entries to keep above the selected entry when scrolling", required = true)
|
||||||
description = "number of entries to keep above the selected entry when scrolling",
|
|
||||||
required = true)
|
|
||||||
private int entriesToKeepOnTopWhenScrolling;
|
private int entriesToKeepOnTopWhenScrolling;
|
||||||
|
|
||||||
@Schema(description = "whether to show the star icon in the header of entries", required = true)
|
@Schema(description = "whether to show the star icon in the header of entries", required = true)
|
||||||
private IconDisplayMode starIconDisplayMode;
|
private IconDisplayMode starIconDisplayMode;
|
||||||
|
|
||||||
@Schema(
|
@Schema(description = "whether to show the external link icon in the header of entries", required = true)
|
||||||
description = "whether to show the external link icon in the header of entries",
|
|
||||||
required = true)
|
|
||||||
private IconDisplayMode externalLinkIconDisplayMode;
|
private IconDisplayMode externalLinkIconDisplayMode;
|
||||||
|
|
||||||
@Schema(description = "ask for confirmation when marking all entries as read", required = true)
|
@Schema(description = "ask for confirmation when marking all entries as read", required = true)
|
||||||
private boolean markAllAsReadConfirmation;
|
private boolean markAllAsReadConfirmation;
|
||||||
|
|
||||||
@Schema(
|
@Schema(
|
||||||
description =
|
description = "navigate to the next unread category or feed after marking all entries as read",
|
||||||
"navigate to the next unread category or feed after marking all entries as read",
|
|
||||||
required = true)
|
required = true)
|
||||||
private boolean markAllAsReadNavigateToNextUnread;
|
private boolean markAllAsReadNavigateToNextUnread;
|
||||||
|
|
||||||
@Schema(description = "show commafeed's own context menu on right click", required = true)
|
@Schema(description = "show commafeed's own context menu on right click", required = true)
|
||||||
private boolean customContextMenu;
|
private boolean customContextMenu;
|
||||||
|
|
||||||
@Schema(
|
@Schema(description = "on mobile, show action buttons at the bottom of the screen", required = true)
|
||||||
description = "on mobile, show action buttons at the bottom of the screen",
|
|
||||||
required = true)
|
|
||||||
private boolean mobileFooter;
|
private boolean mobileFooter;
|
||||||
|
|
||||||
@Schema(description = "show unread count in the title", required = true)
|
@Schema(description = "show unread count in the title", required = true)
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ public class Subscription implements Serializable {
|
|||||||
private Instant lastRefresh;
|
private Instant lastRefresh;
|
||||||
|
|
||||||
@Schema(
|
@Schema(
|
||||||
description =
|
description = "next time the feed refresh is planned, null if refresh is already queued",
|
||||||
"next time the feed refresh is planned, null if refresh is already queued",
|
|
||||||
type = SchemaType.INTEGER)
|
type = SchemaType.INTEGER)
|
||||||
private Instant nextRefresh;
|
private Instant nextRefresh;
|
||||||
|
|
||||||
@@ -63,22 +62,16 @@ public class Subscription implements Serializable {
|
|||||||
@Schema(description = "date of the newest item", type = SchemaType.INTEGER)
|
@Schema(description = "date of the newest item", type = SchemaType.INTEGER)
|
||||||
private Instant newestItemTime;
|
private Instant newestItemTime;
|
||||||
|
|
||||||
@Schema(
|
@Schema(description = "CEL string evaluated on new entries to mark them as read if they do not match")
|
||||||
description =
|
|
||||||
"CEL string evaluated on new entries to mark them as read if they do not match")
|
|
||||||
private String filter;
|
private String filter;
|
||||||
|
|
||||||
@Schema(description = "JEXL legacy filter")
|
@Schema(description = "JEXL legacy filter")
|
||||||
private String filterLegacy;
|
private String filterLegacy;
|
||||||
|
|
||||||
@Schema(
|
@Schema(description = "whether to send push notifications for new entries of this feed", required = true)
|
||||||
description = "whether to send push notifications for new entries of this feed",
|
|
||||||
required = true)
|
|
||||||
private boolean pushNotificationsEnabled;
|
private boolean pushNotificationsEnabled;
|
||||||
|
|
||||||
@Schema(
|
@Schema(description = "automatically mark entries as read after this many days (null to disable)")
|
||||||
description =
|
|
||||||
"automatically mark entries as read after this many days (null to disable)")
|
|
||||||
private Integer autoMarkAsReadAfterDays;
|
private Integer autoMarkAsReadAfterDays;
|
||||||
|
|
||||||
public static Subscription build(FeedSubscription subscription, UnreadCount unreadCount) {
|
public static Subscription build(FeedSubscription subscription, UnreadCount unreadCount) {
|
||||||
|
|||||||
@@ -27,17 +27,13 @@ public class FeedModificationRequest implements Serializable {
|
|||||||
@Schema(description = "new display position, null if not changed")
|
@Schema(description = "new display position, null if not changed")
|
||||||
private Integer position;
|
private Integer position;
|
||||||
|
|
||||||
@Schema(
|
@Schema(description = "CEL string evaluated on new entries to mark them as read if they do not match")
|
||||||
description =
|
|
||||||
"CEL string evaluated on new entries to mark them as read if they do not match")
|
|
||||||
@Size(max = 4096)
|
@Size(max = 4096)
|
||||||
private String filter;
|
private String filter;
|
||||||
|
|
||||||
@Schema(description = "whether to send push notifications for new entries of this feed")
|
@Schema(description = "whether to send push notifications for new entries of this feed")
|
||||||
private boolean pushNotificationsEnabled;
|
private boolean pushNotificationsEnabled;
|
||||||
|
|
||||||
@Schema(
|
@Schema(description = "automatically mark entries as read after this many days (null to disable)")
|
||||||
description =
|
|
||||||
"automatically mark entries as read after this many days (null to disable)")
|
|
||||||
private Integer autoMarkAsReadAfterDays;
|
private Integer autoMarkAsReadAfterDays;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ public class MarkRequest implements Serializable {
|
|||||||
@Size(max = 128)
|
@Size(max = 128)
|
||||||
private String keywords;
|
private String keywords;
|
||||||
|
|
||||||
@Schema(
|
@Schema(description = "if marking a category or 'all', exclude those subscriptions from the marking")
|
||||||
description =
|
|
||||||
"if marking a category or 'all', exclude those subscriptions from the marking")
|
|
||||||
private List<Long> excludedSubscriptions;
|
private List<Long> excludedSubscriptions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import com.commafeed.backend.dao.UserRoleDAO;
|
|||||||
import com.commafeed.backend.model.User;
|
import com.commafeed.backend.model.User;
|
||||||
import com.commafeed.backend.model.UserRole;
|
import com.commafeed.backend.model.UserRole;
|
||||||
import com.commafeed.backend.model.UserRole.Role;
|
import com.commafeed.backend.model.UserRole.Role;
|
||||||
import com.commafeed.backend.service.PasswordEncryptionService;
|
|
||||||
import com.commafeed.backend.service.UserService;
|
import com.commafeed.backend.service.UserService;
|
||||||
import com.commafeed.frontend.model.UserModel;
|
import com.commafeed.frontend.model.UserModel;
|
||||||
import com.commafeed.frontend.model.request.AdminSaveUserRequest;
|
import com.commafeed.frontend.model.request.AdminSaveUserRequest;
|
||||||
@@ -56,7 +55,6 @@ public class AdminREST {
|
|||||||
private final UserDAO userDAO;
|
private final UserDAO userDAO;
|
||||||
private final UserRoleDAO userRoleDAO;
|
private final UserRoleDAO userRoleDAO;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final PasswordEncryptionService encryptionService;
|
|
||||||
private final MetricRegistry metrics;
|
private final MetricRegistry metrics;
|
||||||
|
|
||||||
@Path("/user/save")
|
@Path("/user/save")
|
||||||
@@ -64,8 +62,7 @@ public class AdminREST {
|
|||||||
@Transactional
|
@Transactional
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "Save or update a user",
|
summary = "Save or update a user",
|
||||||
description =
|
description = "Save or update a user. If the id is not specified, a new user will be created")
|
||||||
"Save or update a user. If the id is not specified, a new user will be created")
|
|
||||||
public Response adminSaveUser(@Valid @Parameter(required = true) AdminSaveUserRequest req) {
|
public Response adminSaveUser(@Valid @Parameter(required = true) AdminSaveUserRequest req) {
|
||||||
Preconditions.checkNotNull(req);
|
Preconditions.checkNotNull(req);
|
||||||
Preconditions.checkNotNull(req.getName());
|
Preconditions.checkNotNull(req.getName());
|
||||||
@@ -79,33 +76,28 @@ public class AdminREST {
|
|||||||
roles.add(Role.ADMIN);
|
roles.add(Role.ADMIN);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
id =
|
id = userService.register(req.getName(), req.getPassword(), req.getEmail(), roles, true).getId();
|
||||||
userService
|
|
||||||
.register(
|
|
||||||
req.getName(),
|
|
||||||
req.getPassword(),
|
|
||||||
req.getEmail(),
|
|
||||||
roles,
|
|
||||||
true)
|
|
||||||
.getId();
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Response.status(Status.CONFLICT).entity(e.getMessage()).build();
|
return Response.status(Status.CONFLICT).entity(e.getMessage()).build();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
User user = authenticationContext.getCurrentUser();
|
User user = authenticationContext.getCurrentUser();
|
||||||
if (req.getId().equals(user.getId()) && !req.isEnabled()) {
|
if (req.getId().equals(user.getId()) && !req.isEnabled()) {
|
||||||
return Response.status(Status.FORBIDDEN)
|
return Response.status(Status.FORBIDDEN).entity("You cannot disable your own account.").build();
|
||||||
.entity("You cannot disable your own account.")
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
User u = userDAO.findById(id);
|
User u = userDAO.findById(id);
|
||||||
u.setName(req.getName());
|
u.setName(req.getName());
|
||||||
if (StringUtils.isNotBlank(req.getPassword())) {
|
if (StringUtils.isNotBlank(req.getPassword())) {
|
||||||
u.setPassword(
|
userService.changePassword(u, req.getPassword());
|
||||||
encryptionService.getEncryptedPassword(req.getPassword(), u.getSalt()));
|
if (StringUtils.isNotBlank(u.getApiKey())) {
|
||||||
|
u.setApiKey(userService.generateApiKey(u));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
u.setEmail(req.getEmail());
|
u.setEmail(req.getEmail());
|
||||||
|
if (!u.isDisabled() && !req.isEnabled()) {
|
||||||
|
userService.invalidateSessions(u);
|
||||||
|
}
|
||||||
u.setDisabled(!req.isEnabled());
|
u.setDisabled(!req.isEnabled());
|
||||||
|
|
||||||
Set<Role> roles = userRoleDAO.findRoles(u);
|
Set<Role> roles = userRoleDAO.findRoles(u);
|
||||||
@@ -131,8 +123,7 @@ public class AdminREST {
|
|||||||
@GET
|
@GET
|
||||||
@Transactional
|
@Transactional
|
||||||
@Operation(summary = "Get user information", description = "Get user information")
|
@Operation(summary = "Get user information", description = "Get user information")
|
||||||
public UserModel adminGetUser(
|
public UserModel adminGetUser(@Parameter(description = "user id", required = true) @PathParam("id") Long id) {
|
||||||
@Parameter(description = "user id", required = true) @PathParam("id") Long id) {
|
|
||||||
Preconditions.checkNotNull(id);
|
Preconditions.checkNotNull(id);
|
||||||
User u = userDAO.findById(id);
|
User u = userDAO.findById(id);
|
||||||
UserModel userModel = new UserModel();
|
UserModel userModel = new UserModel();
|
||||||
@@ -140,8 +131,7 @@ public class AdminREST {
|
|||||||
userModel.setName(u.getName());
|
userModel.setName(u.getName());
|
||||||
userModel.setEmail(u.getEmail());
|
userModel.setEmail(u.getEmail());
|
||||||
userModel.setEnabled(!u.isDisabled());
|
userModel.setEnabled(!u.isDisabled());
|
||||||
userModel.setAdmin(
|
userModel.setAdmin(userRoleDAO.findAll(u).stream().anyMatch(r -> r.getRole() == Role.ADMIN));
|
||||||
userRoleDAO.findAll(u).stream().anyMatch(r -> r.getRole() == Role.ADMIN));
|
|
||||||
return userModel;
|
return userModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,9 +179,7 @@ public class AdminREST {
|
|||||||
|
|
||||||
User user = authenticationContext.getCurrentUser();
|
User user = authenticationContext.getCurrentUser();
|
||||||
if (user.getId().equals(u.getId())) {
|
if (user.getId().equals(u.getId())) {
|
||||||
return Response.status(Status.FORBIDDEN)
|
return Response.status(Status.FORBIDDEN).entity("You cannot delete your own user.").build();
|
||||||
.entity("You cannot delete your own user.")
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
userService.unregister(u);
|
userService.unregister(u);
|
||||||
return Response.ok().build();
|
return Response.ok().build();
|
||||||
|
|||||||
@@ -104,30 +104,22 @@ public class CategoryREST {
|
|||||||
@Operation(summary = "Get category entries", description = "Get a list of category entries")
|
@Operation(summary = "Get category entries", description = "Get a list of category entries")
|
||||||
@APIResponse(
|
@APIResponse(
|
||||||
responseCode = "200",
|
responseCode = "200",
|
||||||
content = {
|
content = {@Content(mediaType = "application/json", schema = @Schema(implementation = Entries.class))})
|
||||||
@Content(
|
|
||||||
mediaType = "application/json",
|
|
||||||
schema = @Schema(implementation = Entries.class))
|
|
||||||
})
|
|
||||||
@APIResponse(responseCode = "404", description = "category not found")
|
@APIResponse(responseCode = "404", description = "category not found")
|
||||||
public Response getCategoryEntries(
|
public Response getCategoryEntries(
|
||||||
@Parameter(description = "id of the category, 'all' or 'starred'", required = true)
|
@Parameter(description = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id")
|
||||||
@QueryParam("id")
|
|
||||||
String id,
|
String id,
|
||||||
@Parameter(description = "all entries or only unread ones", required = true)
|
@Parameter(description = "all entries or only unread ones", required = true)
|
||||||
@DefaultValue("unread")
|
@DefaultValue("unread")
|
||||||
@QueryParam("readType")
|
@QueryParam("readType")
|
||||||
ReadingMode readType,
|
ReadingMode readType,
|
||||||
@Parameter(description = "only entries newer than this") @QueryParam("newerThan")
|
@Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
|
||||||
Long newerThan,
|
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
|
||||||
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset")
|
|
||||||
int offset,
|
|
||||||
@Parameter(description = "limit for paging, default 20, maximum 1000")
|
@Parameter(description = "limit for paging, default 20, maximum 1000")
|
||||||
@DefaultValue("20")
|
@DefaultValue("20")
|
||||||
@QueryParam("limit")
|
@QueryParam("limit")
|
||||||
int limit,
|
int limit,
|
||||||
@Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc")
|
@Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
|
||||||
ReadingOrder order,
|
|
||||||
@Parameter(
|
@Parameter(
|
||||||
description =
|
description =
|
||||||
"search for keywords in either the title or the content of the entries, separated by spaces")
|
"search for keywords in either the title or the content of the entries, separated by spaces")
|
||||||
@@ -136,13 +128,11 @@ public class CategoryREST {
|
|||||||
@Parameter(description = "comma-separated list of excluded subscription ids")
|
@Parameter(description = "comma-separated list of excluded subscription ids")
|
||||||
@QueryParam("excludedSubscriptionIds")
|
@QueryParam("excludedSubscriptionIds")
|
||||||
String excludedSubscriptionIds,
|
String excludedSubscriptionIds,
|
||||||
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag")
|
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) {
|
||||||
String tag) {
|
|
||||||
|
|
||||||
Preconditions.checkNotNull(readType);
|
Preconditions.checkNotNull(readType);
|
||||||
|
|
||||||
List<FeedEntryKeyword> entryKeywords =
|
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(StringUtils.trimToNull(keywords));
|
||||||
FeedEntryKeyword.fromQueryString(StringUtils.trimToNull(keywords));
|
|
||||||
|
|
||||||
limit = Math.min(limit, 1000);
|
limit = Math.min(limit, 1000);
|
||||||
limit = Math.max(0, limit);
|
limit = Math.max(0, limit);
|
||||||
@@ -159,8 +149,7 @@ public class CategoryREST {
|
|||||||
|
|
||||||
List<Long> excludedIds = null;
|
List<Long> excludedIds = null;
|
||||||
if (StringUtils.isNotEmpty(excludedSubscriptionIds)) {
|
if (StringUtils.isNotEmpty(excludedSubscriptionIds)) {
|
||||||
excludedIds =
|
excludedIds = Arrays.stream(excludedSubscriptionIds.split(",")).map(Long::valueOf).toList();
|
||||||
Arrays.stream(excludedSubscriptionIds.split(",")).map(Long::valueOf).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
User user = authenticationContext.getCurrentUser();
|
User user = authenticationContext.getCurrentUser();
|
||||||
@@ -191,18 +180,15 @@ public class CategoryREST {
|
|||||||
} else if (STARRED.equals(id)) {
|
} else if (STARRED.equals(id)) {
|
||||||
entries.setName("Starred");
|
entries.setName("Starred");
|
||||||
List<FeedEntryStatus> starred =
|
List<FeedEntryStatus> starred =
|
||||||
feedEntryStatusDAO.findStarred(
|
feedEntryStatusDAO.findStarred(user, entryKeywords, newerThanDate, offset, limit + 1, order, true);
|
||||||
user, entryKeywords, newerThanDate, offset, limit + 1, order, true);
|
|
||||||
for (FeedEntryStatus status : starred) {
|
for (FeedEntryStatus status : starred) {
|
||||||
entries.getEntries().add(Entry.build(status, config.imageProxyEnabled()));
|
entries.getEntries().add(Entry.build(status, config.imageProxyEnabled()));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(id));
|
FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(id));
|
||||||
if (parent != null) {
|
if (parent != null) {
|
||||||
List<FeedCategory> categories =
|
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, parent);
|
||||||
feedCategoryDAO.findAllChildrenCategories(user, parent);
|
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories);
|
||||||
List<FeedSubscription> subs =
|
|
||||||
feedSubscriptionDAO.findByCategories(user, categories);
|
|
||||||
removeExcludedSubscriptions(subs, excludedIds);
|
removeExcludedSubscriptions(subs, excludedIds);
|
||||||
List<FeedEntryStatus> list =
|
List<FeedEntryStatus> list =
|
||||||
feedEntryStatusDAO.findBySubscriptions(
|
feedEntryStatusDAO.findBySubscriptions(
|
||||||
@@ -224,9 +210,7 @@ public class CategoryREST {
|
|||||||
}
|
}
|
||||||
entries.setName(parent.getName());
|
entries.setName(parent.getName());
|
||||||
} else {
|
} else {
|
||||||
return Response.status(Status.NOT_FOUND)
|
return Response.status(Status.NOT_FOUND).entity("<message>category not found</message>").build();
|
||||||
.entity("<message>category not found</message>")
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,28 +228,22 @@ public class CategoryREST {
|
|||||||
@Path("/entriesAsFeed")
|
@Path("/entriesAsFeed")
|
||||||
@GET
|
@GET
|
||||||
@Transactional
|
@Transactional
|
||||||
@Operation(
|
@Operation(summary = "Get category entries as feed", description = "Get a feed of category entries")
|
||||||
summary = "Get category entries as feed",
|
|
||||||
description = "Get a feed of category entries")
|
|
||||||
@Produces(MediaType.APPLICATION_XML)
|
@Produces(MediaType.APPLICATION_XML)
|
||||||
public Response getCategoryEntriesAsFeed(
|
public Response getCategoryEntriesAsFeed(
|
||||||
@Parameter(description = "id of the category, 'all' or 'starred'", required = true)
|
@Parameter(description = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id")
|
||||||
@QueryParam("id")
|
|
||||||
String id,
|
String id,
|
||||||
@Parameter(description = "all entries or only unread ones", required = true)
|
@Parameter(description = "all entries or only unread ones", required = true)
|
||||||
@DefaultValue("all")
|
@DefaultValue("all")
|
||||||
@QueryParam("readType")
|
@QueryParam("readType")
|
||||||
ReadingMode readType,
|
ReadingMode readType,
|
||||||
@Parameter(description = "only entries newer than this") @QueryParam("newerThan")
|
@Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
|
||||||
Long newerThan,
|
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
|
||||||
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset")
|
|
||||||
int offset,
|
|
||||||
@Parameter(description = "limit for paging, default 20, maximum 1000")
|
@Parameter(description = "limit for paging, default 20, maximum 1000")
|
||||||
@DefaultValue("20")
|
@DefaultValue("20")
|
||||||
@QueryParam("limit")
|
@QueryParam("limit")
|
||||||
int limit,
|
int limit,
|
||||||
@Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc")
|
@Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
|
||||||
ReadingOrder order,
|
|
||||||
@Parameter(
|
@Parameter(
|
||||||
description =
|
description =
|
||||||
"search for keywords in either the title or the content of the entries, separated by spaces")
|
"search for keywords in either the title or the content of the entries, separated by spaces")
|
||||||
@@ -274,20 +252,11 @@ public class CategoryREST {
|
|||||||
@Parameter(description = "comma-separated list of excluded subscription ids")
|
@Parameter(description = "comma-separated list of excluded subscription ids")
|
||||||
@QueryParam("excludedSubscriptionIds")
|
@QueryParam("excludedSubscriptionIds")
|
||||||
String excludedSubscriptionIds,
|
String excludedSubscriptionIds,
|
||||||
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag")
|
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) {
|
||||||
String tag) {
|
|
||||||
|
|
||||||
Response response =
|
Response response =
|
||||||
getCategoryEntries(
|
getCategoryEntries(
|
||||||
id,
|
id, readType, newerThan, offset, limit, order, keywords, excludedSubscriptionIds, tag);
|
||||||
readType,
|
|
||||||
newerThan,
|
|
||||||
offset,
|
|
||||||
limit,
|
|
||||||
order,
|
|
||||||
keywords,
|
|
||||||
excludedSubscriptionIds,
|
|
||||||
tag);
|
|
||||||
if (response.getStatus() != Status.OK.getStatusCode()) {
|
if (response.getStatus() != Status.OK.getStatusCode()) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -314,21 +283,14 @@ public class CategoryREST {
|
|||||||
@Path("/mark")
|
@Path("/mark")
|
||||||
@POST
|
@POST
|
||||||
@Transactional
|
@Transactional
|
||||||
@Operation(
|
@Operation(summary = "Mark category entries", description = "Mark feed entries of this category as read")
|
||||||
summary = "Mark category entries",
|
|
||||||
description = "Mark feed entries of this category as read")
|
|
||||||
public Response markCategoryEntries(
|
public Response markCategoryEntries(
|
||||||
@Valid @Parameter(description = "category id, or 'all'", required = true)
|
@Valid @Parameter(description = "category id, or 'all'", required = true) MarkRequest req) {
|
||||||
MarkRequest req) {
|
|
||||||
Preconditions.checkNotNull(req);
|
Preconditions.checkNotNull(req);
|
||||||
Preconditions.checkNotNull(req.getId());
|
Preconditions.checkNotNull(req.getId());
|
||||||
|
|
||||||
Instant olderThan =
|
Instant olderThan = req.getOlderThan() == null ? null : Instant.ofEpochMilli(req.getOlderThan());
|
||||||
req.getOlderThan() == null ? null : Instant.ofEpochMilli(req.getOlderThan());
|
Instant insertedBefore = req.getInsertedBefore() == null ? null : Instant.ofEpochMilli(req.getInsertedBefore());
|
||||||
Instant insertedBefore =
|
|
||||||
req.getInsertedBefore() == null
|
|
||||||
? null
|
|
||||||
: Instant.ofEpochMilli(req.getInsertedBefore());
|
|
||||||
String keywords = req.getKeywords();
|
String keywords = req.getKeywords();
|
||||||
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
|
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
|
||||||
|
|
||||||
@@ -336,8 +298,7 @@ public class CategoryREST {
|
|||||||
if (ALL.equals(req.getId())) {
|
if (ALL.equals(req.getId())) {
|
||||||
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
|
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
|
||||||
removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
|
removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
|
||||||
feedEntryService.markSubscriptionEntries(
|
feedEntryService.markSubscriptionEntries(user, subs, olderThan, insertedBefore, entryKeywords);
|
||||||
user, subs, olderThan, insertedBefore, entryKeywords);
|
|
||||||
} else if (STARRED.equals(req.getId())) {
|
} else if (STARRED.equals(req.getId())) {
|
||||||
feedEntryService.markStarredEntries(user, olderThan, insertedBefore);
|
feedEntryService.markStarredEntries(user, olderThan, insertedBefore);
|
||||||
} else {
|
} else {
|
||||||
@@ -345,8 +306,7 @@ public class CategoryREST {
|
|||||||
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, parent);
|
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, parent);
|
||||||
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories);
|
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories);
|
||||||
removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
|
removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
|
||||||
feedEntryService.markSubscriptionEntries(
|
feedEntryService.markSubscriptionEntries(user, subs, olderThan, insertedBefore, entryKeywords);
|
||||||
user, subs, olderThan, insertedBefore, entryKeywords);
|
|
||||||
}
|
}
|
||||||
return Response.ok().build();
|
return Response.ok().build();
|
||||||
}
|
}
|
||||||
@@ -400,8 +360,7 @@ public class CategoryREST {
|
|||||||
|
|
||||||
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, cat);
|
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, cat);
|
||||||
for (FeedCategory child : categories) {
|
for (FeedCategory child : categories) {
|
||||||
if (!child.getId().equals(cat.getId())
|
if (!child.getId().equals(cat.getId()) && child.getParent().getId().equals(cat.getId())) {
|
||||||
&& child.getParent().getId().equals(cat.getId())) {
|
|
||||||
child.setParent(null);
|
child.setParent(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,8 +376,7 @@ public class CategoryREST {
|
|||||||
@Path("/modify")
|
@Path("/modify")
|
||||||
@Transactional
|
@Transactional
|
||||||
@Operation(summary = "Modify a category", description = "Modify an existing feed category")
|
@Operation(summary = "Modify a category", description = "Modify an existing feed category")
|
||||||
public Response modifyCategory(
|
public Response modifyCategory(@Valid @Parameter(required = true) CategoryModificationRequest req) {
|
||||||
@Valid @Parameter(required = true) CategoryModificationRequest req) {
|
|
||||||
Preconditions.checkNotNull(req);
|
Preconditions.checkNotNull(req);
|
||||||
Preconditions.checkNotNull(req.getId());
|
Preconditions.checkNotNull(req.getId());
|
||||||
|
|
||||||
@@ -437,14 +395,11 @@ public class CategoryREST {
|
|||||||
}
|
}
|
||||||
if (parent != null) {
|
if (parent != null) {
|
||||||
Long parentId = parent.getId();
|
Long parentId = parent.getId();
|
||||||
List<FeedCategory> descendants =
|
List<FeedCategory> descendants = feedCategoryDAO.findAllChildrenCategories(user, category);
|
||||||
feedCategoryDAO.findAllChildrenCategories(user, category);
|
boolean parentIsDescendant = descendants.stream().anyMatch(c -> c.getId().equals(parentId));
|
||||||
boolean parentIsDescendant =
|
|
||||||
descendants.stream().anyMatch(c -> c.getId().equals(parentId));
|
|
||||||
if (parentIsDescendant) {
|
if (parentIsDescendant) {
|
||||||
return Response.status(Status.BAD_REQUEST)
|
return Response.status(Status.BAD_REQUEST)
|
||||||
.entity(
|
.entity("<message>cannot set a category's own descendant as its parent</message>")
|
||||||
"<message>cannot set a category's own descendant as its parent</message>")
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -476,9 +431,7 @@ public class CategoryREST {
|
|||||||
@POST
|
@POST
|
||||||
@Path("/collapse")
|
@Path("/collapse")
|
||||||
@Transactional
|
@Transactional
|
||||||
@Operation(
|
@Operation(summary = "Collapse a category", description = "Save collapsed or expanded status for a category")
|
||||||
summary = "Collapse a category",
|
|
||||||
description = "Save collapsed or expanded status for a category")
|
|
||||||
public Response collapseCategory(@Parameter(required = true) CollapseRequest req) {
|
public Response collapseCategory(@Parameter(required = true) CollapseRequest req) {
|
||||||
Preconditions.checkNotNull(req);
|
Preconditions.checkNotNull(req);
|
||||||
Preconditions.checkNotNull(req.getId());
|
Preconditions.checkNotNull(req.getId());
|
||||||
@@ -506,9 +459,7 @@ public class CategoryREST {
|
|||||||
@GET
|
@GET
|
||||||
@Path("/get")
|
@Path("/get")
|
||||||
@Transactional
|
@Transactional
|
||||||
@Operation(
|
@Operation(summary = "Get root category", description = "Get all categories and subscriptions of the user")
|
||||||
summary = "Get root category",
|
|
||||||
description = "Get all categories and subscriptions of the user")
|
|
||||||
public Category getRootCategory() {
|
public Category getRootCategory() {
|
||||||
User user = authenticationContext.getCurrentUser();
|
User user = authenticationContext.getCurrentUser();
|
||||||
|
|
||||||
@@ -551,8 +502,7 @@ public class CategoryREST {
|
|||||||
|
|
||||||
for (FeedSubscription subscription : subscriptions) {
|
for (FeedSubscription subscription : subscriptions) {
|
||||||
if (id == null && subscription.getCategory() == null
|
if (id == null && subscription.getCategory() == null
|
||||||
|| subscription.getCategory() != null
|
|| subscription.getCategory() != null && Objects.equals(subscription.getCategory().getId(), id)) {
|
||||||
&& Objects.equals(subscription.getCategory().getId(), id)) {
|
|
||||||
UnreadCount uc = unreadCount.get(subscription.getId());
|
UnreadCount uc = unreadCount.get(subscription.getId());
|
||||||
Subscription sub = Subscription.build(subscription, uc);
|
Subscription sub = Subscription.build(subscription, uc);
|
||||||
category.getFeeds().add(sub);
|
category.getFeeds().add(sub);
|
||||||
|
|||||||
@@ -50,8 +50,7 @@ public class EntryREST {
|
|||||||
@POST
|
@POST
|
||||||
@Transactional
|
@Transactional
|
||||||
@Operation(summary = "Mark a feed entry", description = "Mark a feed entry as read/unread")
|
@Operation(summary = "Mark a feed entry", description = "Mark a feed entry as read/unread")
|
||||||
public Response markEntry(
|
public Response markEntry(@Valid @Parameter(description = "Mark Request", required = true) MarkRequest req) {
|
||||||
@Valid @Parameter(description = "Mark Request", required = true) MarkRequest req) {
|
|
||||||
Preconditions.checkNotNull(req);
|
Preconditions.checkNotNull(req);
|
||||||
Preconditions.checkNotNull(req.getId());
|
Preconditions.checkNotNull(req.getId());
|
||||||
|
|
||||||
@@ -63,12 +62,9 @@ public class EntryREST {
|
|||||||
@Path("/markMultiple")
|
@Path("/markMultiple")
|
||||||
@POST
|
@POST
|
||||||
@Transactional
|
@Transactional
|
||||||
@Operation(
|
@Operation(summary = "Mark multiple feed entries", description = "Mark feed entries as read/unread")
|
||||||
summary = "Mark multiple feed entries",
|
|
||||||
description = "Mark feed entries as read/unread")
|
|
||||||
public Response markEntries(
|
public Response markEntries(
|
||||||
@Valid @Parameter(description = "Multiple Mark Request", required = true)
|
@Valid @Parameter(description = "Multiple Mark Request", required = true) MultipleMarkRequest req) {
|
||||||
MultipleMarkRequest req) {
|
|
||||||
Preconditions.checkNotNull(req);
|
Preconditions.checkNotNull(req);
|
||||||
Preconditions.checkNotNull(req.getRequests());
|
Preconditions.checkNotNull(req.getRequests());
|
||||||
|
|
||||||
@@ -85,8 +81,7 @@ public class EntryREST {
|
|||||||
@POST
|
@POST
|
||||||
@Transactional
|
@Transactional
|
||||||
@Operation(summary = "Star a feed entry", description = "Mark a feed entry as read/unread")
|
@Operation(summary = "Star a feed entry", description = "Mark a feed entry as read/unread")
|
||||||
public Response starEntry(
|
public Response starEntry(@Valid @Parameter(description = "Star Request", required = true) StarRequest req) {
|
||||||
@Valid @Parameter(description = "Star Request", required = true) StarRequest req) {
|
|
||||||
Preconditions.checkNotNull(req);
|
Preconditions.checkNotNull(req);
|
||||||
Preconditions.checkNotNull(req.getId());
|
Preconditions.checkNotNull(req.getId());
|
||||||
|
|
||||||
@@ -99,9 +94,7 @@ public class EntryREST {
|
|||||||
@Path("/tags")
|
@Path("/tags")
|
||||||
@GET
|
@GET
|
||||||
@Transactional
|
@Transactional
|
||||||
@Operation(
|
@Operation(summary = "Get list of tags for the user", description = "Get list of tags for the user")
|
||||||
summary = "Get list of tags for the user",
|
|
||||||
description = "Get list of tags for the user")
|
|
||||||
public Response getTags() {
|
public Response getTags() {
|
||||||
User user = authenticationContext.getCurrentUser();
|
User user = authenticationContext.getCurrentUser();
|
||||||
List<String> tags = feedEntryTagDAO.findByUser(user);
|
List<String> tags = feedEntryTagDAO.findByUser(user);
|
||||||
@@ -112,8 +105,7 @@ public class EntryREST {
|
|||||||
@POST
|
@POST
|
||||||
@Transactional
|
@Transactional
|
||||||
@Operation(summary = "Set feed entry tags")
|
@Operation(summary = "Set feed entry tags")
|
||||||
public Response tagEntry(
|
public Response tagEntry(@Valid @Parameter(description = "Tag Request", required = true) TagRequest req) {
|
||||||
@Valid @Parameter(description = "Tag Request", required = true) TagRequest req) {
|
|
||||||
Preconditions.checkNotNull(req);
|
Preconditions.checkNotNull(req);
|
||||||
Preconditions.checkNotNull(req.getEntryId());
|
Preconditions.checkNotNull(req.getEntryId());
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user