Compare commits

...

107 Commits
4.4.1 ... 4.6.0

Author SHA1 Message Date
Athou
346fb6b1ea release 4.6.0 2024-07-15 16:03:09 +02:00
Athou
1b658c76a3 show both read and unread entries when searching with keywords 2024-07-15 12:41:13 +02:00
Athou
1593ed62ba github actions is slow, increase timeout 2024-07-15 11:11:19 +02:00
Athou
085eddd4b0 fill the shared classes cache of openj9 even more 2024-07-15 10:57:26 +02:00
Jérémie Panzer
0db77ad2c0 Merge pull request #1487 from Athou/renovate/com.manticore-projects.tools-h2migrationtool-1.x
Update dependency com.manticore-projects.tools:h2migrationtool to v1.7
2024-07-15 10:41:13 +02:00
renovate[bot]
6f8bcb6c6a Update dependency com.manticore-projects.tools:h2migrationtool to v1.7 2024-07-15 07:43:51 +00:00
renovate[bot]
4196dee896 Lock file maintenance 2024-07-15 01:09:26 +00:00
Athou
6d49e0f0df build openj9 shared classes cache to improve startup time 2024-07-14 22:26:39 +02:00
Athou
d99f572989 move env variable definition before adding files in order to maximize layer reusability 2024-07-14 21:14:39 +02:00
Athou
fa197c33f1 rename field accordingly 2024-07-14 20:37:01 +02:00
Athou
1ce39a419e use "published" instead of "updated" (#1486) 2024-07-14 19:53:35 +02:00
Athou
f0e3ac8fcb README tweaks 2024-07-14 09:35:44 +02:00
renovate[bot]
30947cea05 Update mantine monorepo to ^7.11.2 2024-07-13 15:46:24 +00:00
Athou
9134f36d3b use openj9 as the Java runtime to reduce memory usage 2024-07-13 13:48:34 +02:00
Athou
dc526316a0 enable string deduplication to reduce memory usage 2024-07-13 10:25:32 +02:00
renovate[bot]
6593174668 Update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.3.1 2024-07-11 01:02:47 +00:00
renovate[bot]
0891c41abc Update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.3.1 2024-07-10 22:28:34 +00:00
renovate[bot]
6ecb6254aa Update dependency npm to v10.8.2 2024-07-10 19:02:33 +00:00
renovate[bot]
84bd9eeeff Update dependency vitest to ^2.0.2 2024-07-10 16:49:16 +00:00
Jérémie Panzer
2549c4d47b Merge pull request #1485 from Athou/renovate/org.jsoup-jsoup-1.x
Update dependency org.jsoup:jsoup to v1.18.1
2024-07-10 13:16:58 +02:00
renovate[bot]
8750aa3dd6 Update dependency org.jsoup:jsoup to v1.18.1 2024-07-10 11:02:42 +00:00
Athou
262094a736 remove dangling comment 2024-07-10 08:55:45 +02:00
Jérémie Panzer
035201f917 Merge pull request #1483 from Athou/renovate/major-vitest-monorepo
Update dependency vitest to v2
2024-07-09 03:47:48 +02:00
Jérémie Panzer
ae9cbc5214 Merge pull request #1484 from Athou/renovate/node-20.15.x
Update dependency node to v20.15.1
2024-07-09 03:42:53 +02:00
Athou
78d5bf129a fix build 2024-07-09 03:42:38 +02:00
renovate[bot]
1f02ddd163 Update dependency node to v20.15.1 2024-07-08 20:17:38 +00:00
renovate[bot]
eff1e8cc7b Update dependency vitest to v2 2024-07-08 16:18:13 +00:00
Jérémie Panzer
dc8475b59a Merge pull request #1482 from Athou/renovate/lock-file-maintenance
Lock file maintenance
2024-07-08 07:05:27 +02:00
renovate[bot]
921968662d Lock file maintenance 2024-07-08 02:21:45 +00:00
Athou
4d83173dbd release 4.5.0 2024-07-06 21:29:21 +02:00
Athou
f13368cb96 remove unnecessary joins 2024-07-06 13:40:53 +02:00
Athou
ec7e97e1de abort current request if we're changing what we're going to display 2024-07-04 07:19:16 +02:00
Athou
d4c9bd1dd7 remove warnings 2024-07-03 20:11:51 +02:00
renovate[bot]
6bff657d4d Update linguijs monorepo to ^4.11.2 2024-07-03 17:13:52 +00:00
renovate[bot]
613d286be1 Update dependency react-router-dom to ^6.24.1 2024-07-03 16:05:19 +00:00
Athou
fd48108f8b don't rely on dates to know if an entry has been inserted in the database 2024-07-03 17:53:41 +02:00
Athou
c3cbd18df9 add debug logging 2024-07-03 17:40:23 +02:00
Athou
6685057dae notify over websocket after everything has been committed 2024-07-03 17:27:17 +02:00
Athou
0dec0e3788 fix a race condition where a feed could be refreshed before it was created 2024-07-03 14:21:40 +02:00
Athou
1a73dd4004 the feed refresh engine is now fast enough, it doesn't need workarounds anymore 2024-07-03 13:30:25 +02:00
Athou
eae80a6450 remove support for microsoft sqlserver, AFAIK nobody's using it and it's not covered with integration tests 2024-07-03 13:02:20 +02:00
Jérémie Panzer
21a32ce0eb Merge pull request #1479 from Athou/renovate/mysql-9.x
Update mysql Docker tag to v9
2024-07-03 11:35:35 +02:00
renovate[bot]
325533c5d9 Update mysql Docker tag to v9 2024-07-03 09:10:22 +00:00
renovate[bot]
7d819022f6 Update redis Docker tag to v7.2.5 2024-07-03 09:10:19 +00:00
Athou
dba944874b add redis integration test 2024-07-03 11:09:23 +02:00
Jérémie Panzer
ce9c12ec92 Merge pull request #1478 from Athou/renovate/postgres-16.x
Update postgres Docker tag to v16.3
2024-07-03 09:23:35 +02:00
Jérémie Panzer
22dfc5774f Merge pull request #1477 from Athou/renovate/mariadb-11.x
Update mariadb Docker tag to v11.4.2
2024-07-03 09:23:24 +02:00
renovate[bot]
d59091ab2b Update postgres Docker tag to v16.3 2024-07-03 07:08:14 +00:00
renovate[bot]
f69146a6bf Update mariadb Docker tag to v11.4.2 2024-07-03 07:08:11 +00:00
Athou
43cdf3db3b add integration tests for postgresql, mysql and mariadb using testcontainers 2024-07-03 09:02:18 +02:00
renovate[bot]
280a354228 Update dependency vite-plugin-biome to ^1.0.12 2024-07-03 06:39:02 +00:00
renovate[bot]
573b0431f9 Update dependency vite to ^5.3.3 2024-07-03 06:32:04 +00:00
renovate[bot]
9878b60e97 Update dependency io.github.git-commit-id:git-commit-id-maven-plugin to v9.0.1 2024-07-02 18:02:54 +00:00
renovate[bot]
964033c2a7 Update mantine monorepo to ^7.11.1 2024-07-02 14:12:43 +00:00
Jérémie Panzer
d2e45aca91 Merge pull request #1475 from Athou/renovate/com.mysql-mysql-connector-j-9.x
Update dependency com.mysql:mysql-connector-j to v9
2024-07-02 16:11:43 +02:00
renovate[bot]
daa99a2efc Update dependency com.mysql:mysql-connector-j to v9 2024-07-02 10:08:39 +00:00
Jérémie Panzer
e986e9999a Merge pull request #1473 from Athou/renovate/node-20.x
Update dependency node to v20.15.0
2024-07-02 09:19:55 +02:00
Jérémie Panzer
98d302cb94 Merge pull request #1474 from Athou/renovate/npm-10.x
Update dependency npm to v10.8.1
2024-07-02 09:19:44 +02:00
renovate[bot]
bf11c4a7e4 Update dependency npm to v10.8.1 2024-07-02 07:13:46 +00:00
renovate[bot]
e1cab952f8 Update dependency node to v20.15.0 2024-07-02 07:13:42 +00:00
Athou
bc28d4de27 renovate can now update node and npm 2024-07-02 09:12:42 +02:00
renovate[bot]
bb901564e3 Update dependency typescript to ^5.5.3 2024-07-01 22:21:16 +00:00
Athou
93acc9ded1 we don't need the user we already have the subscription 2024-07-01 13:55:50 +02:00
renovate[bot]
9b1c6a371e Lock file maintenance 2024-07-01 07:50:40 +00:00
Athou
82bf8cd807 fetch all tags at once 2024-07-01 08:52:05 +02:00
Athou
c2f2780c3f remove unused onlyIds parameter 2024-07-01 08:52:05 +02:00
Athou
08f71d1f6f implement faster querying by fetching directly what we need 2024-07-01 08:52:05 +02:00
Athou
f498088beb no need to insert statuses that will be collected during next cleanup 2024-06-30 21:56:42 +02:00
Athou
347b41cf35 fix exception when trying to mark starred entries as read 2024-06-30 16:50:37 +02:00
renovate[bot]
61ae90ad28 Update dependency @reduxjs/toolkit to ^2.2.6 2024-06-29 14:12:13 +00:00
Athou
9a42fbafb2 only automerge patch updates, keep creating pull requests for minor updates 2024-06-29 08:47:49 +02:00
Athou
938f9e9434 remove automergePr because we now specify automergeBranch 2024-06-28 07:27:58 +02:00
Jérémie Panzer
9004e453c2 Merge pull request #1470 from Athou/renovate/querydsl.version
Update querydsl.version to v6.5
2024-06-28 07:26:21 +02:00
renovate[bot]
7d33542691 Update querydsl.version to v6.5 2024-06-28 05:25:56 +00:00
Athou
c99348862c reduce renovate noise by automerging if tests pass 2024-06-28 07:25:10 +02:00
Jérémie Panzer
ac86db3966 Merge pull request #1463 from Athou/renovate/com.manticore-projects.tools-h2migrationtool-1.x
Update dependency com.manticore-projects.tools:h2migrationtool to v1.6
2024-06-28 07:21:29 +02:00
Athou
e368810731 adapt script to new H2MigrationTool file output name 2024-06-28 07:16:42 +02:00
renovate[bot]
edae2f5a61 Update dependency com.manticore-projects.tools:h2migrationtool to v1.6 2024-06-28 03:34:34 +00:00
renovate[bot]
ab17c6f44e Update dependency org.projectlombok:lombok to v1.18.34 2024-06-28 03:34:21 +00:00
renovate[bot]
59dbae4f66 Update dependency com.microsoft.playwright:playwright to v1.45.0 2024-06-28 01:23:51 +00:00
renovate[bot]
d7956292df Update dependency vite-plugin-biome to ^1.0.11 2024-06-27 21:19:36 +00:00
renovate[bot]
1075497559 Update dependency vite to ^5.3.2 2024-06-27 19:44:59 +00:00
renovate[bot]
2d99fa03d3 Update dependency @biomejs/biome to v1.8.3 2024-06-27 16:49:21 +00:00
Jérémie Panzer
72b64b6f0d Merge pull request #1465 from Athou/renovate/mantine-monorepo
Update mantine monorepo to ^7.11.0
2024-06-27 09:35:00 +02:00
Jérémie Panzer
a2096d3622 Merge pull request #1457 from Athou/renovate/monaco-editor-0.x
Update dependency monaco-editor to ^0.50.0
2024-06-27 09:34:52 +02:00
Jérémie Panzer
c81f9fb7b1 Merge pull request #1459 from Athou/renovate/throttle-debounce-5.x
Update dependency throttle-debounce to ^5.0.2
2024-06-27 09:34:44 +02:00
Jérémie Panzer
cc7e9e21fb Merge pull request #1461 from Athou/renovate/react-router-monorepo
Update dependency react-router-dom to ^6.24.0
2024-06-27 09:34:35 +02:00
Jérémie Panzer
803d537e51 Merge pull request #1456 from Athou/renovate/biomejs-biome-1.x
Update dependency @biomejs/biome to v1.8.2
2024-06-27 09:34:20 +02:00
Jérémie Panzer
9a83e5b6ef Merge pull request #1458 from Athou/renovate/typescript-5.x
Update dependency typescript to ^5.5.2
2024-06-27 09:34:08 +02:00
renovate[bot]
4323da9007 Update mantine monorepo to ^7.11.0 2024-06-27 07:28:56 +00:00
renovate[bot]
30b9b24be4 Update dependency typescript to ^5.5.2 2024-06-27 07:28:42 +00:00
renovate[bot]
b191b00003 Update dependency react-router-dom to ^6.24.0 2024-06-27 07:28:30 +00:00
renovate[bot]
7e5cdcba34 Update dependency monaco-editor to ^0.50.0 2024-06-27 07:28:16 +00:00
renovate[bot]
45b30ad333 Update dependency throttle-debounce to ^5.0.2 2024-06-27 07:27:58 +00:00
renovate[bot]
7ca087b0a6 Update dependency @biomejs/biome to v1.8.2 2024-06-27 07:27:47 +00:00
Jérémie Panzer
188e4594fd automerge small dependency updates if they pass tests 2024-06-27 09:26:48 +02:00
Jérémie Panzer
2da80ce7d8 Merge pull request #1453 from Athou/renovate/org.apache.maven.plugins-maven-jar-plugin-3.x
Update dependency org.apache.maven.plugins:maven-jar-plugin to v3.4.2
2024-06-20 14:09:39 +02:00
renovate[bot]
d5820f9aa5 Update dependency org.apache.maven.plugins:maven-jar-plugin to v3.4.2 2024-06-19 18:42:15 +00:00
Athou
b1a0aae0a5 treat javac warnings as errors 2024-06-19 19:41:09 +02:00
Athou
cdd4d4b063 change BaseIT test class so that authentication with the "admin" user is not the default 2024-06-19 19:41:02 +02:00
Jérémie Panzer
21f675e80b Merge pull request #1451 from Athou/renovate/querydsl.version
Update querydsl.version to v6.4
2024-06-19 18:53:35 +02:00
renovate[bot]
380724d73e Update querydsl.version to v6.4 2024-06-19 16:49:05 +00:00
Athou
2d26c5dee3 remove empty file 2024-06-18 20:40:36 +02:00
Jérémie Panzer
29bcc5ccf5 Merge pull request #1437 from Athou/renovate/maven-3.x
Update dependency maven to v3.9.8
2024-06-17 22:31:07 +02:00
Jérémie Panzer
91497ab45a Merge pull request #1450 from Athou/renovate/docker-build-push-action-6.x
Update docker/build-push-action action to v6
2024-06-17 22:30:49 +02:00
renovate[bot]
be77968570 Update docker/build-push-action action to v6 2024-06-17 10:38:49 +00:00
renovate[bot]
a42dacc48d Update dependency maven to v3.9.8 2024-06-17 10:38:45 +00:00
68 changed files with 2978 additions and 1721 deletions

View File

@@ -3,4 +3,6 @@
# allow only what we need # allow only what we need
!commafeed-server/target/commafeed.jar !commafeed-server/target/commafeed.jar
!commafeed-server/config.docker-warmup.yml
!commafeed-server/config.yml.example !commafeed-server/config.yml.example

View File

@@ -29,10 +29,34 @@ jobs:
distribution: "temurin" distribution: "temurin"
cache: "maven" cache: "maven"
# Build # Build & Test
- name: Build with Maven - name: Build with Maven
run: mvn --batch-mode --update-snapshots verify run: mvn --batch-mode --no-transfer-progress install
env:
TEST_DATABASE: h2
- name: Run integration tests on PostgreSQL
run: mvn --batch-mode --no-transfer-progress failsafe:integration-test failsafe:verify
env:
TEST_DATABASE: postgresql
- name: Run integration tests on MySQL
run: mvn --batch-mode --no-transfer-progress failsafe:integration-test failsafe:verify
env:
TEST_DATABASE: mysql
- name: Run integration tests on MariaDB
run: mvn --batch-mode --no-transfer-progress failsafe:integration-test failsafe:verify
env:
TEST_DATABASE: mariadb
- name: Run integration tests with Redis cache enabled
run: mvn --batch-mode --no-transfer-progress failsafe:integration-test failsafe:verify
env:
TEST_DATABASE: h2
REDIS: true
# Upload artifacts
- name: Upload JAR - name: Upload JAR
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: ${{ matrix.java == '17' }} if: ${{ matrix.java == '17' }}
@@ -57,7 +81,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker build and push tag - name: Docker build and push tag
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
if: ${{ matrix.java == '17' && github.ref_type == 'tag' }} if: ${{ matrix.java == '17' && github.ref_type == 'tag' }}
with: with:
context: . context: .
@@ -68,7 +92,7 @@ jobs:
athou/commafeed:${{ github.ref_name }} athou/commafeed:${{ github.ref_name }}
- name: Docker build and push master - name: Docker build and push master
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
if: ${{ matrix.java == '17' && github.ref_name == 'master' }} if: ${{ matrix.java == '17' && github.ref_name == 'master' }}
with: with:
context: . context: .

View File

@@ -15,4 +15,4 @@
# specific language governing permissions and limitations # specific language governing permissions and limitations
# under the License. # under the License.
distributionType=only-script distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.7/apache-maven-3.9.7-bin.zip distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.8/apache-maven-3.9.8-bin.zip

View File

@@ -1,5 +1,24 @@
# Changelog # Changelog
## [4.6.0]
- switched from Temurin to OpenJ9 as the JVM used in the Docker image, resulting in memory usage reduction by up to 50%
- fix an issue that could cause old entries to reappear if they were updated by their author (#1486)
- show all entries regardless of their read status when searching with keywords, even if the ui is configured to show
unread entries only
## [4.5.0]
- significantly reduce the time needed to retrieve entries or mark them as read, especially when there are a lot of
entries (#1452)
- fix a race condition where a feed could be refreshed before it was created in the database
- fix an issue that could cause the websocket notification to contain the wrong number of unread entries when using
mysql/mariadb
- fix an error when trying to mark all starred entries as read
- remove the `onlyIds` parameter from REST endpoints since retrieving all the entries is now just as fast
- remove support for microsoft sqlserver because it's not covered with integration tests (please open an issue if you'd
like it back)
## [4.4.1] ## [4.4.1]
- fix vertical scrolling issues with Safari (#1168) - fix vertical scrolling issues with Safari (#1168)

View File

@@ -1,12 +1,19 @@
FROM eclipse-temurin:21.0.3_9-jre FROM ibm-semeru-runtimes:open-21-jre
EXPOSE 8082 EXPOSE 8082
RUN mkdir -p /commafeed/data RUN mkdir -p /commafeed/data
VOLUME /commafeed/data VOLUME /commafeed/data
RUN apt update && apt install -y wait-for-it && apt clean
ENV JAVA_TOOL_OPTIONS -Djava.net.preferIPv4Stack=true -Xtune:virtualized -Xminf0.05 -Xmaxf0.1
COPY commafeed-server/config.docker-warmup.yml .
COPY commafeed-server/config.yml.example config.yml COPY commafeed-server/config.yml.example config.yml
COPY commafeed-server/target/commafeed.jar . COPY commafeed-server/target/commafeed.jar .
ENV JAVA_TOOL_OPTIONS -Djava.net.preferIPv4Stack=true -Xms20m -XX:+UseG1GC -XX:-ShrinkHeapInSteps -XX:G1PeriodicGCInterval=10000 -XX:-G1PeriodicGCInvokesConcurrent -XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=10 # build openj9 shared classes cache to improve startup time
CMD ["java", "-jar", "commafeed.jar", "server", "config.yml"] RUN sh -c 'java -Xshareclasses -jar commafeed.jar server config.docker-warmup.yml &' ; wait-for-it -t 600 localhost:8088 -- pkill java ; rm -rf config.warmup.yml
CMD ["java", "-Xshareclasses", "-jar", "commafeed.jar", "server", "config.yml"]

View File

@@ -58,7 +58,7 @@ user is `admin` and the default password is `admin`.
The Java Virtual Machine (JVM) is rather greedy by default and will not release unused memory to the The Java Virtual Machine (JVM) is rather greedy by default and will not release unused memory to the
operating system. This is because acquiring memory from the operating system is a relatively expensive operation. operating system. This is because acquiring memory from the operating system is a relatively expensive operation.
However, this can be problematic on systems with limited memory. This can be problematic on systems with limited memory.
#### Hard limit #### Hard limit
@@ -67,16 +67,25 @@ For example, to limit the JVM to 256MB of memory, use `-Xmx256m`.
#### Dynamic sizing #### Dynamic sizing
The JVM can be configured to release unused memory to the operating system with the following parameters: In addition to the previous setting, the JVM can be configured to release unused memory to the operating system with the
following parameters:
-Xms20m -XX:+UseG1GC -XX:-ShrinkHeapInSteps -XX:G1PeriodicGCInterval=10000 -XX:-G1PeriodicGCInvokesConcurrent -XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=10 -Xms20m -XX:+UseG1GC -XX:+UseStringDeduplication -XX:-ShrinkHeapInSteps -XX:G1PeriodicGCInterval=10000 -XX:-G1PeriodicGCInvokesConcurrent -XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=10
This is how the Docker image is configured.
See [here](https://docs.oracle.com/en/java/javase/17/gctuning/garbage-first-g1-garbage-collector1.html) See [here](https://docs.oracle.com/en/java/javase/17/gctuning/garbage-first-g1-garbage-collector1.html)
and [here](https://docs.oracle.com/en/java/javase/17/gctuning/factors-affecting-garbage-collection-performance.html) for and [here](https://docs.oracle.com/en/java/javase/17/gctuning/factors-affecting-garbage-collection-performance.html) for
more more
information. information.
#### OpenJ9
The [OpenJ9](https://eclipse.dev/openj9/) JVM is a more memory-efficient alternative to the HotSpot JVM, at the cost of
slightly slower throughput.
IBM provides precompiled binaries for OpenJ9
named [Semeru](https://developer.ibm.com/languages/java/semeru-runtimes/downloads/).
This is the JVM used in the [Docker image](https://github.com/Athou/commafeed/blob/master/Dockerfile).
## Translation ## Translation
Files for internationalization are Files for internationalization are

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/1.8.1/schema.json", "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
"formatter": { "formatter": {
"indentStyle": "space", "indentStyle": "space",
"indentWidth": 4, "indentWidth": 4,

File diff suppressed because it is too large Load Diff

View File

@@ -17,22 +17,22 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.4", "@emotion/react": "^11.11.4",
"@fontsource/open-sans": "^5.0.28", "@fontsource/open-sans": "^5.0.28",
"@lingui/core": "^4.11.1", "@lingui/core": "^4.11.2",
"@lingui/macro": "^4.11.1", "@lingui/macro": "^4.11.2",
"@lingui/react": "^4.11.1", "@lingui/react": "^4.11.2",
"@mantine/core": "^7.10.2", "@mantine/core": "^7.11.2",
"@mantine/form": "^7.10.2", "@mantine/form": "^7.11.2",
"@mantine/hooks": "^7.10.2", "@mantine/hooks": "^7.11.2",
"@mantine/modals": "^7.10.2", "@mantine/modals": "^7.11.2",
"@mantine/notifications": "^7.10.2", "@mantine/notifications": "^7.11.2",
"@mantine/spotlight": "^7.10.2", "@mantine/spotlight": "^7.11.2",
"@monaco-editor/react": "^4.6.0", "@monaco-editor/react": "^4.6.0",
"@reduxjs/toolkit": "^2.2.5", "@reduxjs/toolkit": "^2.2.6",
"axios": "^1.7.2", "axios": "^1.7.2",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"escape-string-regexp": "^5.0.0", "escape-string-regexp": "^5.0.0",
"interweave": "^13.1.0", "interweave": "^13.1.0",
"monaco-editor": "^0.49.0", "monaco-editor": "^0.50.0",
"mousetrap": "^1.6.5", "mousetrap": "^1.6.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-async-hook": "^4.0.0", "react-async-hook": "^4.0.0",
@@ -45,20 +45,20 @@
"react-icons": "^5.2.1", "react-icons": "^5.2.1",
"react-infinite-scroller": "^1.2.6", "react-infinite-scroller": "^1.2.6",
"react-redux": "^9.1.2", "react-redux": "^9.1.2",
"react-router-dom": "^6.23.1", "react-router-dom": "^6.24.1",
"react-swipeable": "^7.0.1", "react-swipeable": "^7.0.1",
"redoc": "^2.1.5", "redoc": "^2.1.5",
"throttle-debounce": "^5.0.0", "throttle-debounce": "^5.0.2",
"tinycon": "^0.6.8", "tinycon": "^0.6.8",
"tss-react": "^4.9.10", "tss-react": "^4.9.10",
"use-local-storage": "^3.0.0", "use-local-storage": "^3.0.0",
"vite-plugin-biome": "^1.0.10", "vite-plugin-biome": "^1.0.12",
"websocket-heartbeat-js": "^1.1.3" "websocket-heartbeat-js": "^1.1.3"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.8.1", "@biomejs/biome": "^1.8.3",
"@lingui/cli": "^4.11.1", "@lingui/cli": "^4.11.2",
"@lingui/vite-plugin": "^4.11.1", "@lingui/vite-plugin": "^4.11.2",
"@types/mousetrap": "^1.6.15", "@types/mousetrap": "^1.6.15",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
@@ -70,10 +70,10 @@
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"babel-plugin-macros": "^3.1.0", "babel-plugin-macros": "^3.1.0",
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^5.12.0",
"typescript": "^5.4.5", "typescript": "^5.5.3",
"vite": "^5.3.1", "vite": "^5.3.3",
"vite-tsconfig-paths": "^4.3.2", "vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.6.0", "vitest": "^2.0.2",
"vitest-mock-extended": "^1.3.1" "vitest-mock-extended": "^1.3.1"
} }
} }

View File

@@ -1,15 +1,23 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>com.commafeed</groupId> <groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId> <artifactId>commafeed</artifactId>
<version>4.4.1</version> <version>4.6.0</version>
</parent> </parent>
<artifactId>commafeed-client</artifactId> <artifactId>commafeed-client</artifactId>
<name>CommaFeed Client</name> <name>CommaFeed Client</name>
<properties>
<!-- renovate: datasource=node-version depName=node -->
<node.version>v20.15.1</node.version>
<!-- renovate: datasource=npm depName=npm -->
<npm.version>10.8.2</npm.version>
</properties>
<build> <build>
<plugins> <plugins>
<plugin> <plugin>
@@ -25,8 +33,8 @@
</goals> </goals>
<phase>compile</phase> <phase>compile</phase>
<configuration> <configuration>
<nodeVersion>v20.10.0</nodeVersion> <nodeVersion>${node.version}</nodeVersion>
<npmVersion>10.2.5</npmVersion> <npmVersion>${npm.version}</npmVersion>
</configuration> </configuration>
</execution> </execution>
<execution> <execution>

View File

@@ -5,7 +5,7 @@ import { type RootState, reducers } from "app/store"
import type { Entries, Entry } from "app/types" import type { Entries, Entry } from "app/types"
import type { AxiosResponse } from "axios" import type { AxiosResponse } from "axios"
import { beforeEach, describe, expect, it, vi } from "vitest" import { beforeEach, describe, expect, it, vi } from "vitest"
import { mockReset } from "vitest-mock-extended" import { any, mockReset } from "vitest-mock-extended"
const mockClient = await vi.hoisted(async () => { const mockClient = await vi.hoisted(async () => {
const mockModule = await import("vitest-mock-extended") const mockModule = await import("vitest-mock-extended")
@@ -19,7 +19,7 @@ describe("entries", () => {
}) })
it("loads entries", async () => { it("loads entries", async () => {
mockClient.feed.getEntries.mockResolvedValue({ mockClient.feed.getEntries.calledWith(any()).mockResolvedValue({
data: { data: {
entries: [{ id: "3" } as Entry], entries: [{ id: "3" } as Entry],
hasMore: false, hasMore: false,
@@ -53,7 +53,7 @@ describe("entries", () => {
}) })
it("loads more entries", async () => { it("loads more entries", async () => {
mockClient.category.getEntries.mockResolvedValue({ mockClient.category.getEntries.calledWith(any()).mockResolvedValue({
data: { data: {
entries: [{ id: "4" } as Entry], entries: [{ id: "4" } as Entry],
hasMore: false, hasMore: false,

View File

@@ -40,7 +40,7 @@ export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_,
const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({ const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({
id: source.type === "tag" ? Constants.categories.all.id : source.id, id: source.type === "tag" ? Constants.categories.all.id : source.id,
order: state.user.settings?.readingOrder, order: state.user.settings?.readingOrder,
readType: state.user.settings?.readingMode, readType: state.entries.search ? "all" : state.user.settings?.readingMode,
offset, offset,
limit: 50, limit: 50,
tag: source.type === "tag" ? source.id : undefined, tag: source.type === "tag" ? source.id : undefined,

View File

@@ -117,7 +117,6 @@ export interface GetEntriesRequest {
newerThan?: number newerThan?: number
order?: ReadingOrder order?: ReadingOrder
keywords?: string keywords?: string
onlyIds?: boolean
excludedSubscriptionIds?: string excludedSubscriptionIds?: string
tag?: string tag?: string
} }

View File

@@ -64,7 +64,7 @@ export function FeedEntriesPage(props: FeedEntriesPageProps) {
// biome-ignore lint/correctness/useExhaustiveDependencies: we subscribe to state.timestamp because we want to reload entries even if the props are the same // biome-ignore lint/correctness/useExhaustiveDependencies: we subscribe to state.timestamp because we want to reload entries even if the props are the same
useEffect(() => { useEffect(() => {
dispatch( const promise = dispatch(
loadEntries({ loadEntries({
source: { source: {
type: props.sourceType, type: props.sourceType,
@@ -73,6 +73,7 @@ export function FeedEntriesPage(props: FeedEntriesPageProps) {
clearSearch: true, clearSearch: true,
}) })
) )
return () => promise.abort()
}, [dispatch, props.sourceType, id, location.state?.timestamp]) }, [dispatch, props.sourceType, id, location.state?.timestamp])
const noSubscriptions = rootCategory && flattenCategoryTree(rootCategory).every(c => c.feeds.length === 0) const noSubscriptions = rootCategory && flattenCategoryTree(rootCategory).every(c => c.feeds.length === 0)

View File

@@ -15,7 +15,6 @@ export default defineConfig(env => ({
}, },
}), }),
lingui(), lingui(),
// https://github.com/vitest-dev/vitest/issues/4055#issuecomment-1732994672
tsconfigPaths(), tsconfigPaths(),
visualizer(), visualizer(),
biomePlugin({ biomePlugin({

View File

@@ -104,10 +104,6 @@ app:
# for PostgreSQL # for PostgreSQL
# driverClass is org.postgresql.Driver # driverClass is org.postgresql.Driver
# url is jdbc:postgresql://localhost:5432/commafeed # url is jdbc:postgresql://localhost:5432/commafeed
#
# for Microsoft SQL Server
# driverClass is net.sourceforge.jtds.jdbc.Driver
# url is jdbc:jtds:sqlserver://localhost:1433/commafeed;instance=<instanceName, remove if not needed>
database: database:
driverClass: org.h2.Driver driverClass: org.h2.Driver

View File

@@ -0,0 +1,151 @@
# CommaFeed settings
# ------------------
app:
# url used to access commafeed
publicUrl: http://localhost:8088/
# whether to expose a robots.txt file that disallows web crawlers and search engine indexers
hideFromWebCrawlers: true
# whether to allow user registrations
allowRegistrations: true
# whether to enable strict password validation (1 uppercase char, 1 lowercase char, 1 digit, 1 special char)
strictPasswordPolicy: true
# create a demo account the first time the app starts
createDemoAccount: true
# put your google analytics tracking code here
googleAnalyticsTrackingCode:
# put your google server key (used for youtube favicon fetching)
googleAuthKey:
# number of http threads
backgroundThreads: 3
# number of database updating threads
databaseUpdateThreads: 1
# rows to delete per query while cleaning up old entries
databaseCleanupBatchSize: 100
# settings for sending emails (password recovery)
smtpHost: localhost
smtpPort: 25
smtpTls: false
smtpUserName: user
smtpPassword: pass
smtpFromAddress:
# Graphite Metric settings
# Allows those who use Graphite to have CommaFeed send metrics for graphing (time in seconds)
graphiteEnabled: false
graphitePrefix: "test.commafeed"
graphiteHost: "localhost"
graphitePort: 2003
graphiteInterval: 60
# whether this commafeed instance has a lot of feeds to refresh
# leave this to false in almost all cases
heavyLoad: false
# minimum amount of time commafeed will wait before refreshing the same feed
refreshIntervalMinutes: 5
# if enabled, images in feed entries will be proxied through the server instead of accessed directly by the browser
# useful if commafeed is usually accessed through a restricting proxy
imageProxyEnabled: true
# database query timeout (in milliseconds), 0 to disable
queryTimeout: 0
# time to keep unread statuses (in days), 0 to disable
keepStatusDays: 0
# entries to keep per feed, old entries will be deleted, 0 to disable
maxFeedCapacity: 500
# entries older than this will be deleted, 0 to disable
maxEntriesAgeDays: 365
# limit the number of feeds a user can subscribe to, 0 to disable
maxFeedsPerUser: 0
# cache service to use, possible values are 'noop' and 'redis'
cache: noop
# announcement string displayed on the main page
announcement:
# user-agent string that will be used by the http client, leave empty for the default one
userAgent:
# enable websocket connection so the server can notify the web client that there are new entries for your feeds
websocketEnabled: true
# interval at which the client will send a ping message on the websocket to keep the connection alive
websocketPingInterval: 15m
# if websocket is disabled or the connection is lost, the client will reload the feed tree at this interval
treeReloadInterval: 30s
# Database connection
# -------------------
# for MariaDB
# driverClass is org.mariadb.jdbc.Driver
# url is jdbc:mariadb://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC
#
# for MySQL
# driverClass is com.mysql.cj.jdbc.Driver
# url is jdbc:mysql://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC
#
# for PostgreSQL
# driverClass is org.postgresql.Driver
# url is jdbc:postgresql://localhost:5432/commafeed
database:
driverClass: org.h2.Driver
url: jdbc:h2:mem:commafeed
user: sa
password: sa
properties:
charSet: UTF-8
validationQuery: "/* CommaFeed Health Check */ SELECT 1"
server:
applicationConnectors:
- type: http
port: 8088
logging:
level: INFO
loggers:
com.commafeed: DEBUG
liquibase: INFO
org.hibernate.SQL: INFO # or ALL for sql debugging
org.hibernate.engine.internal.StatisticalLoggingSessionEventListener: WARN
appenders:
- type: console
- type: file
currentLogFilename: log/commafeed.log
threshold: ALL
archive: true
archivedLogFilenamePattern: log/commafeed-%d.log
archivedFileCount: 5
timeZone: UTC
# Redis pool configuration
# (only used if app.cache is 'redis')
# -----------------------------------
redis:
host: localhost
port: 6379
# username is only required when using ACLs
username:
password:
timeout: 2000
database: 0
maxTotal: 500

View File

@@ -104,10 +104,6 @@ app:
# for PostgreSQL # for PostgreSQL
# driverClass is org.postgresql.Driver # driverClass is org.postgresql.Driver
# url is jdbc:postgresql://localhost:5432/commafeed # url is jdbc:postgresql://localhost:5432/commafeed
#
# for Microsoft SQL Server
# driverClass is net.sourceforge.jtds.jdbc.Driver
# url is jdbc:jtds:sqlserver://localhost:1433/commafeed;instance=<instanceName, remove if not needed>
database: database:
driverClass: org.h2.Driver driverClass: org.h2.Driver

View File

@@ -7,7 +7,7 @@ services:
- MYSQL_ROOT_PASSWORD=root - MYSQL_ROOT_PASSWORD=root
- MYSQL_DATABASE=commafeed - MYSQL_DATABASE=commafeed
ports: ports:
- 3306:3306 - "3306:3306"
postgresql: postgresql:
image: postgres image: postgres
@@ -16,4 +16,4 @@ services:
- POSTGRES_PASSWORD=root - POSTGRES_PASSWORD=root
- POSTGRES_DB=commafeed - POSTGRES_DB=commafeed
ports: ports:
- 5432:5432 - "5432:5432"

View File

@@ -6,15 +6,25 @@
<parent> <parent>
<groupId>com.commafeed</groupId> <groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId> <artifactId>commafeed</artifactId>
<version>4.4.1</version> <version>4.6.0</version>
</parent> </parent>
<artifactId>commafeed-server</artifactId> <artifactId>commafeed-server</artifactId>
<name>CommaFeed Server</name> <name>CommaFeed Server</name>
<properties> <properties>
<guice.version>7.0.0</guice.version> <guice.version>7.0.0</guice.version>
<querydsl.version>6.3</querydsl.version> <querydsl.version>6.5</querydsl.version>
<rome.version>2.1.0</rome.version> <rome.version>2.1.0</rome.version>
<testcontainers.version>1.19.8</testcontainers.version>
<!-- renovate: datasource=docker depName=postgres -->
<postgresql.image.version>16.3</postgresql.image.version>
<!-- renovate: datasource=docker depName=mysql -->
<mysql.image.version>9.0.0</mysql.image.version>
<!-- renovate: datasource=docker depName=mariadb -->
<mariadb.image.version>11.4.2</mariadb.image.version>
<!-- renovate: datasource=docker depName=redis -->
<redis.image.version>7.2.5</redis.image.version>
</properties> </properties>
<dependencyManagement> <dependencyManagement>
@@ -31,17 +41,30 @@
<build> <build>
<finalName>commafeed</finalName> <finalName>commafeed</finalName>
<testResources>
<testResource>
<directory>src/test/resources</directory>
<filtering>false</filtering>
</testResource>
<testResource>
<directory>src/test/resources</directory>
<includes>
<include>docker-images.properties</include>
</includes>
<filtering>true</filtering>
</testResource>
</testResources>
<plugins> <plugins>
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId> <artifactId>maven-surefire-plugin</artifactId>
<version>3.3.0</version> <version>3.3.1</version>
</plugin> </plugin>
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId> <artifactId>maven-failsafe-plugin</artifactId>
<version>3.3.0</version> <version>3.3.1</version>
<executions> <executions>
<execution> <execution>
<goals> <goals>
@@ -54,7 +77,7 @@
<plugin> <plugin>
<groupId>io.github.git-commit-id</groupId> <groupId>io.github.git-commit-id</groupId>
<artifactId>git-commit-id-maven-plugin</artifactId> <artifactId>git-commit-id-maven-plugin</artifactId>
<version>9.0.0</version> <version>9.0.1</version>
<executions> <executions>
<execution> <execution>
<goals> <goals>
@@ -64,7 +87,8 @@
</executions> </executions>
<configuration> <configuration>
<generateGitPropertiesFile>true</generateGitPropertiesFile> <generateGitPropertiesFile>true</generateGitPropertiesFile>
<generateGitPropertiesFilename>${project.build.outputDirectory}/git.properties</generateGitPropertiesFilename> <generateGitPropertiesFilename>${project.build.outputDirectory}/git.properties
</generateGitPropertiesFilename>
<failOnNoGitDirectory>false</failOnNoGitDirectory> <failOnNoGitDirectory>false</failOnNoGitDirectory>
<failOnUnableToExtractRepoInfo>false</failOnUnableToExtractRepoInfo> <failOnUnableToExtractRepoInfo>false</failOnUnableToExtractRepoInfo>
</configuration> </configuration>
@@ -86,6 +110,7 @@
<filter> <filter>
<artifact>*:*</artifact> <artifact>*:*</artifact>
<excludes> <excludes>
<exclude>module-info.class</exclude>
<exclude>META-INF/*.SF</exclude> <exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude> <exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude> <exclude>META-INF/*.RSA</exclude>
@@ -145,7 +170,7 @@
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId> <artifactId>maven-jar-plugin</artifactId>
<version>3.4.1</version> <version>3.4.2</version>
<configuration> <configuration>
<archive> <archive>
<manifest> <manifest>
@@ -211,13 +236,13 @@
<dependency> <dependency>
<groupId>com.commafeed</groupId> <groupId>com.commafeed</groupId>
<artifactId>commafeed-client</artifactId> <artifactId>commafeed-client</artifactId>
<version>4.4.1</version> <version>4.6.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
<version>1.18.32</version> <version>1.18.34</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<dependency> <dependency>
@@ -358,7 +383,7 @@
<dependency> <dependency>
<groupId>org.jsoup</groupId> <groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId> <artifactId>jsoup</artifactId>
<version>1.17.2</version> <version>1.18.1</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.ibm.icu</groupId> <groupId>com.ibm.icu</groupId>
@@ -403,13 +428,13 @@
<dependency> <dependency>
<groupId>com.manticore-projects.tools</groupId> <groupId>com.manticore-projects.tools</groupId>
<artifactId>h2migrationtool</artifactId> <artifactId>h2migrationtool</artifactId>
<version>1.4</version> <version>1.7</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.mysql</groupId> <groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId> <artifactId>mysql-connector-j</artifactId>
<version>8.4.0</version> <version>9.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.mariadb.jdbc</groupId> <groupId>org.mariadb.jdbc</groupId>
@@ -421,11 +446,6 @@
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
<version>42.7.3</version> <version>42.7.3</version>
</dependency> </dependency>
<dependency>
<groupId>net.sourceforge.jtds</groupId>
<artifactId>jtds</artifactId>
<version>1.3.1</version>
</dependency>
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
@@ -467,9 +487,33 @@
<dependency> <dependency>
<groupId>com.microsoft.playwright</groupId> <groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId> <artifactId>playwright</artifactId>
<version>1.44.0</version> <version>1.45.0</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mariadb</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -98,9 +98,9 @@ public class CommaFeedApplication extends Application<CommaFeedConfiguration> {
configureObjectMapper(bootstrap.getObjectMapper()); configureObjectMapper(bootstrap.getObjectMapper());
// run h2 migration as the first bundle because we need to migrate before hibernate is initialized // run h2 migration as the first bundle because we need to migrate before hibernate is initialized
bootstrap.addBundle(new ConfiguredBundle<CommaFeedConfiguration>() { bootstrap.addBundle(new ConfiguredBundle<>() {
@Override @Override
public void run(CommaFeedConfiguration config, Environment environment) throws Exception { public void run(CommaFeedConfiguration config, Environment environment) {
DataSourceFactory dataSourceFactory = config.getDataSourceFactory(); DataSourceFactory dataSourceFactory = config.getDataSourceFactory();
String url = dataSourceFactory.getUrl(); String url = dataSourceFactory.getUrl();
if (isFileBasedH2(url)) { if (isFileBasedH2(url)) {

View File

@@ -1,49 +0,0 @@
package com.commafeed.backend;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
/**
* List wrapper that sorts its elements in the order provided by given comparator and ensure a maximum capacity.
*
*
*/
public class FixedSizeSortedList<E> {
private final List<E> inner;
private final Comparator<? super E> comparator;
private final int capacity;
public FixedSizeSortedList(int capacity, Comparator<? super E> comparator) {
this.inner = new ArrayList<>(Math.max(0, capacity));
this.capacity = capacity < 0 ? Integer.MAX_VALUE : capacity;
this.comparator = comparator;
}
public void add(E e) {
int position = Math.abs(Collections.binarySearch(inner, e, comparator) + 1);
if (isFull()) {
if (position < inner.size()) {
inner.remove(inner.size() - 1);
inner.add(position, e);
}
} else {
inner.add(position, e);
}
}
public E last() {
return inner.get(inner.size() - 1);
}
public boolean isFull() {
return inner.size() == capacity;
}
public List<E> asList() {
return inner;
}
}

View File

@@ -17,7 +17,7 @@ import jakarta.inject.Singleton;
@Singleton @Singleton
public class FeedCategoryDAO extends GenericDAO<FeedCategory> { public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
private final QFeedCategory category = QFeedCategory.feedCategory; private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory;
@Inject @Inject
public FeedCategoryDAO(SessionFactory sessionFactory) { public FeedCategoryDAO(SessionFactory sessionFactory) {
@@ -25,31 +25,31 @@ public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
} }
public List<FeedCategory> findAll(User user) { public List<FeedCategory> findAll(User user) {
return query().selectFrom(category).where(category.user.eq(user)).join(category.user, QUser.user).fetchJoin().fetch(); return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user)).join(CATEGORY.user, QUser.user).fetchJoin().fetch();
} }
public FeedCategory findById(User user, Long id) { public FeedCategory findById(User user, Long id) {
return query().selectFrom(category).where(category.user.eq(user), category.id.eq(id)).fetchOne(); return query().selectFrom(CATEGORY).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) {
Predicate parentPredicate; Predicate parentPredicate;
if (parent == null) { if (parent == null) {
parentPredicate = category.parent.isNull(); parentPredicate = CATEGORY.parent.isNull();
} else { } else {
parentPredicate = category.parent.eq(parent); parentPredicate = CATEGORY.parent.eq(parent);
} }
return query().selectFrom(category).where(category.user.eq(user), category.name.eq(name), parentPredicate).fetchOne(); return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), CATEGORY.name.eq(name), parentPredicate).fetchOne();
} }
public List<FeedCategory> findByParent(User user, FeedCategory parent) { public List<FeedCategory> findByParent(User user, FeedCategory parent) {
Predicate parentPredicate; Predicate parentPredicate;
if (parent == null) { if (parent == null) {
parentPredicate = category.parent.isNull(); parentPredicate = CATEGORY.parent.isNull();
} else { } else {
parentPredicate = category.parent.eq(parent); parentPredicate = CATEGORY.parent.eq(parent);
} }
return query().selectFrom(category).where(category.user.eq(user), parentPredicate).fetch(); return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), parentPredicate).fetch();
} }
public List<FeedCategory> findAllChildrenCategories(User user, FeedCategory parent) { public List<FeedCategory> findAllChildrenCategories(User user, FeedCategory parent) {

View File

@@ -18,8 +18,8 @@ import jakarta.inject.Singleton;
@Singleton @Singleton
public class FeedDAO extends GenericDAO<Feed> { public class FeedDAO extends GenericDAO<Feed> {
private final QFeed feed = QFeed.feed; private static final QFeed FEED = QFeed.feed;
private final QFeedSubscription subscription = QFeedSubscription.feedSubscription; private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription;
@Inject @Inject
public FeedDAO(SessionFactory sessionFactory) { public FeedDAO(SessionFactory sessionFactory) {
@@ -27,25 +27,25 @@ public class FeedDAO extends GenericDAO<Feed> {
} }
public List<Feed> findNextUpdatable(int count, Instant lastLoginThreshold) { public List<Feed> findNextUpdatable(int count, Instant lastLoginThreshold) {
JPAQuery<Feed> query = query().selectFrom(feed).where(feed.disabledUntil.isNull().or(feed.disabledUntil.lt(Instant.now()))); JPAQuery<Feed> query = query().selectFrom(FEED).where(FEED.disabledUntil.isNull().or(FEED.disabledUntil.lt(Instant.now())));
if (lastLoginThreshold != null) { if (lastLoginThreshold != null) {
query.where(JPAExpressions.selectOne() query.where(JPAExpressions.selectOne()
.from(subscription) .from(SUBSCRIPTION)
.join(subscription.user) .join(SUBSCRIPTION.user)
.where(subscription.feed.id.eq(feed.id), subscription.user.lastLogin.gt(lastLoginThreshold)) .where(SUBSCRIPTION.feed.id.eq(FEED.id), SUBSCRIPTION.user.lastLogin.gt(lastLoginThreshold))
.exists()); .exists());
} }
return query.orderBy(feed.disabledUntil.asc()).limit(count).fetch(); return query.orderBy(FEED.disabledUntil.asc()).limit(count).fetch();
} }
public void setDisabledUntil(List<Long> feedIds, Instant date) { public void setDisabledUntil(List<Long> feedIds, Instant date) {
updateQuery(feed).set(feed.disabledUntil, date).where(feed.id.in(feedIds)).execute(); updateQuery(FEED).set(FEED.disabledUntil, date).where(FEED.id.in(feedIds)).execute();
} }
public Feed findByUrl(String normalizedUrl, String normalizedUrlHash) { public Feed findByUrl(String normalizedUrl, String normalizedUrlHash) {
return query().selectFrom(feed) return query().selectFrom(FEED)
.where(feed.normalizedUrlHash.eq(normalizedUrlHash)) .where(FEED.normalizedUrlHash.eq(normalizedUrlHash))
.fetch() .fetch()
.stream() .stream()
.filter(f -> StringUtils.equals(normalizedUrl, f.getNormalizedUrl())) .filter(f -> StringUtils.equals(normalizedUrl, f.getNormalizedUrl()))
@@ -55,6 +55,6 @@ public class FeedDAO extends GenericDAO<Feed> {
public List<Feed> findWithoutSubscriptions(int max) { public List<Feed> findWithoutSubscriptions(int max) {
QFeedSubscription sub = QFeedSubscription.feedSubscription; QFeedSubscription sub = QFeedSubscription.feedSubscription;
return query().selectFrom(feed).where(JPAExpressions.selectOne().from(sub).where(sub.feed.eq(feed)).notExists()).limit(max).fetch(); return query().selectFrom(FEED).where(JPAExpressions.selectOne().from(sub).where(sub.feed.eq(FEED)).notExists()).limit(max).fetch();
} }
} }

View File

@@ -16,8 +16,8 @@ import jakarta.inject.Singleton;
@Singleton @Singleton
public class FeedEntryContentDAO extends GenericDAO<FeedEntryContent> { public class FeedEntryContentDAO extends GenericDAO<FeedEntryContent> {
private final QFeedEntryContent content = QFeedEntryContent.feedEntryContent; private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent;
private final QFeedEntry entry = QFeedEntry.feedEntry; private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
@Inject @Inject
public FeedEntryContentDAO(SessionFactory sessionFactory) { public FeedEntryContentDAO(SessionFactory sessionFactory) {
@@ -25,13 +25,13 @@ public class FeedEntryContentDAO extends GenericDAO<FeedEntryContent> {
} }
public List<FeedEntryContent> findExisting(String contentHash, String titleHash) { public List<FeedEntryContent> findExisting(String contentHash, String titleHash) {
return query().select(content).from(content).where(content.contentHash.eq(contentHash), content.titleHash.eq(titleHash)).fetch(); return query().select(CONTENT).from(CONTENT).where(CONTENT.contentHash.eq(contentHash), CONTENT.titleHash.eq(titleHash)).fetch();
} }
public long deleteWithoutEntries(int max) { public long deleteWithoutEntries(int max) {
JPQLSubQuery<Integer> subQuery = JPAExpressions.selectOne().from(entry).where(entry.content.id.eq(content.id)); JPQLSubQuery<Integer> subQuery = JPAExpressions.selectOne().from(ENTRY).where(ENTRY.content.id.eq(CONTENT.id));
List<Long> ids = query().select(content.id).from(content).where(subQuery.notExists()).limit(max).fetch(); List<Long> ids = query().select(CONTENT.id).from(CONTENT).where(subQuery.notExists()).limit(max).fetch();
return deleteQuery(content).where(content.id.in(ids)).execute(); return deleteQuery(CONTENT).where(CONTENT.id.in(ids)).execute();
} }
} }

View File

@@ -19,7 +19,7 @@ import lombok.Getter;
@Singleton @Singleton
public class FeedEntryDAO extends GenericDAO<FeedEntry> { public class FeedEntryDAO extends GenericDAO<FeedEntry> {
private final QFeedEntry entry = QFeedEntry.feedEntry; private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
@Inject @Inject
public FeedEntryDAO(SessionFactory sessionFactory) { public FeedEntryDAO(SessionFactory sessionFactory) {
@@ -27,22 +27,22 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
} }
public FeedEntry findExisting(String guidHash, Feed feed) { public FeedEntry findExisting(String guidHash, Feed feed) {
return query().select(entry).from(entry).where(entry.guidHash.eq(guidHash), entry.feed.eq(feed)).limit(1).fetchOne(); return query().select(ENTRY).from(ENTRY).where(ENTRY.guidHash.eq(guidHash), ENTRY.feed.eq(feed)).limit(1).fetchOne();
} }
public List<FeedCapacity> findFeedsExceedingCapacity(long maxCapacity, long max) { public List<FeedCapacity> findFeedsExceedingCapacity(long maxCapacity, long max) {
NumberExpression<Long> count = entry.id.count(); NumberExpression<Long> count = ENTRY.id.count();
List<Tuple> tuples = query().select(entry.feed.id, count) List<Tuple> tuples = query().select(ENTRY.feed.id, count)
.from(entry) .from(ENTRY)
.groupBy(entry.feed) .groupBy(ENTRY.feed)
.having(count.gt(maxCapacity)) .having(count.gt(maxCapacity))
.limit(max) .limit(max)
.fetch(); .fetch();
return tuples.stream().map(t -> new FeedCapacity(t.get(entry.feed.id), t.get(count))).toList(); return tuples.stream().map(t -> new FeedCapacity(t.get(ENTRY.feed.id), t.get(count))).toList();
} }
public int delete(Long feedId, long max) { public int delete(Long feedId, long max) {
List<FeedEntry> list = query().selectFrom(entry).where(entry.feed.id.eq(feedId)).limit(max).fetch(); List<FeedEntry> list = query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).limit(max).fetch();
return delete(list); return delete(list);
} }
@@ -50,7 +50,11 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
* Delete entries older than a certain date * Delete entries older than a certain date
*/ */
public int deleteEntriesOlderThan(Instant olderThan, long max) { public int deleteEntriesOlderThan(Instant olderThan, long max) {
List<FeedEntry> list = query().selectFrom(entry).where(entry.updated.lt(olderThan)).orderBy(entry.updated.asc()).limit(max).fetch(); List<FeedEntry> list = query().selectFrom(ENTRY)
.where(ENTRY.published.lt(olderThan))
.orderBy(ENTRY.published.asc())
.limit(max)
.fetch();
return delete(list); return delete(list);
} }
@@ -58,7 +62,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) { public int deleteOldEntries(Long feedId, long max) {
List<FeedEntry> list = query().selectFrom(entry).where(entry.feed.id.eq(feedId)).orderBy(entry.updated.asc()).limit(max).fetch(); List<FeedEntry> list = query().selectFrom(ENTRY).where(ENTRY.feed.id.eq(feedId)).orderBy(ENTRY.published.asc()).limit(max).fetch();
return delete(list); return delete(list);
} }

View File

@@ -2,23 +2,20 @@ package com.commafeed.backend.dao;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.builder.CompareToBuilder;
import org.hibernate.SessionFactory; import org.hibernate.SessionFactory;
import com.commafeed.CommaFeedConfiguration; import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.FixedSizeSortedList;
import com.commafeed.backend.feed.FeedEntryKeyword; import com.commafeed.backend.feed.FeedEntryKeyword;
import com.commafeed.backend.feed.FeedEntryKeyword.Mode; import com.commafeed.backend.feed.FeedEntryKeyword.Mode;
import com.commafeed.backend.model.FeedEntry; import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryContent;
import com.commafeed.backend.model.FeedEntryStatus; import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedEntryTag; import com.commafeed.backend.model.FeedEntryTag;
import com.commafeed.backend.model.FeedSubscription; import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.Models;
import com.commafeed.backend.model.QFeedEntry; import com.commafeed.backend.model.QFeedEntry;
import com.commafeed.backend.model.QFeedEntryContent; import com.commafeed.backend.model.QFeedEntryContent;
import com.commafeed.backend.model.QFeedEntryStatus; import com.commafeed.backend.model.QFeedEntryStatus;
@@ -27,7 +24,6 @@ import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings.ReadingOrder; import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.frontend.model.UnreadCount; import com.commafeed.frontend.model.UnreadCount;
import com.google.common.collect.Iterables; import com.google.common.collect.Iterables;
import com.google.common.collect.Ordering;
import com.querydsl.core.BooleanBuilder; import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.Tuple; import com.querydsl.core.Tuple;
import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQuery;
@@ -38,43 +34,34 @@ import jakarta.inject.Singleton;
@Singleton @Singleton
public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> { public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
private static final Comparator<FeedEntryStatus> STATUS_COMPARATOR_DESC = (o1, o2) -> { private static final QFeedEntryStatus STATUS = QFeedEntryStatus.feedEntryStatus;
CompareToBuilder builder = new CompareToBuilder(); private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
builder.append(o2.getEntryUpdated(), o1.getEntryUpdated()); private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent;
builder.append(o2.getId(), o1.getId()); private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag;
return builder.toComparison();
};
private static final Comparator<FeedEntryStatus> STATUS_COMPARATOR_ASC = Ordering.from(STATUS_COMPARATOR_DESC).reverse();
private final FeedEntryDAO feedEntryDAO;
private final FeedEntryTagDAO feedEntryTagDAO; private final FeedEntryTagDAO feedEntryTagDAO;
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
private final QFeedEntryStatus status = QFeedEntryStatus.feedEntryStatus;
private final QFeedEntry entry = QFeedEntry.feedEntry;
private final QFeedEntryContent content = QFeedEntryContent.feedEntryContent;
private final QFeedEntryTag entryTag = QFeedEntryTag.feedEntryTag;
@Inject @Inject
public FeedEntryStatusDAO(SessionFactory sessionFactory, FeedEntryDAO feedEntryDAO, FeedEntryTagDAO feedEntryTagDAO, public FeedEntryStatusDAO(SessionFactory sessionFactory, FeedEntryTagDAO feedEntryTagDAO, CommaFeedConfiguration config) {
CommaFeedConfiguration config) {
super(sessionFactory); super(sessionFactory);
this.feedEntryDAO = feedEntryDAO;
this.feedEntryTagDAO = feedEntryTagDAO; this.feedEntryTagDAO = feedEntryTagDAO;
this.config = config; this.config = config;
} }
public FeedEntryStatus getStatus(User user, FeedSubscription sub, FeedEntry entry) { public FeedEntryStatus getStatus(User user, FeedSubscription sub, FeedEntry entry) {
List<FeedEntryStatus> statuses = query().selectFrom(status).where(status.entry.eq(entry), status.subscription.eq(sub)).fetch(); List<FeedEntryStatus> statuses = query().selectFrom(STATUS).where(STATUS.entry.eq(entry), STATUS.subscription.eq(sub)).fetch();
FeedEntryStatus status = Iterables.getFirst(statuses, null); FeedEntryStatus status = Iterables.getFirst(statuses, null);
return handleStatus(user, status, sub, entry); return handleStatus(user, status, sub, entry);
} }
/**
* creates an artificial "unread" status if status is null
*/
private FeedEntryStatus handleStatus(User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) { private FeedEntryStatus handleStatus(User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) {
if (status == null) { if (status == null) {
Instant unreadThreshold = config.getApplicationSettings().getUnreadThreshold(); Instant unreadThreshold = config.getApplicationSettings().getUnreadThreshold();
boolean read = unreadThreshold != null && entry.getUpdated().isBefore(unreadThreshold); boolean read = unreadThreshold != null && entry.getPublished().isBefore(unreadThreshold);
status = new FeedEntryStatus(user, sub, entry); status = new FeedEntryStatus(user, sub, entry);
status.setRead(read); status.setRead(read);
status.setMarkable(!read); status.setMarkable(!read);
@@ -84,101 +71,103 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
return status; return status;
} }
private FeedEntryStatus fetchTags(User user, FeedEntryStatus status) { private void fetchTags(User user, List<FeedEntryStatus> statuses) {
List<FeedEntryTag> tags = feedEntryTagDAO.findByEntry(user, status.getEntry()); Map<Long, List<FeedEntryTag>> tagsByEntryIds = feedEntryTagDAO.findByEntries(user,
status.setTags(tags); statuses.stream().map(FeedEntryStatus::getEntry).toList());
return status; for (FeedEntryStatus status : statuses) {
List<FeedEntryTag> tags = tagsByEntryIds.get(status.getEntry().getId());
status.setTags(tags == null ? List.of() : tags);
}
} }
public List<FeedEntryStatus> findStarred(User user, Instant newerThan, int offset, int limit, ReadingOrder order, public List<FeedEntryStatus> findStarred(User user, Instant newerThan, int offset, int limit, ReadingOrder order,
boolean includeContent) { boolean includeContent) {
JPAQuery<FeedEntryStatus> query = query().selectFrom(status).where(status.user.eq(user), status.starred.isTrue()); JPAQuery<FeedEntryStatus> query = query().selectFrom(STATUS).where(STATUS.user.eq(user), STATUS.starred.isTrue());
if (includeContent) {
query.join(STATUS.entry.content).fetchJoin();
}
if (newerThan != null) { if (newerThan != null) {
query.where(status.entryInserted.gt(newerThan)); query.where(STATUS.entryInserted.gt(newerThan));
} }
if (order == ReadingOrder.asc) { if (order == ReadingOrder.asc) {
query.orderBy(status.entryUpdated.asc(), status.id.asc()); query.orderBy(STATUS.entryPublished.asc(), STATUS.id.asc());
} else { } else {
query.orderBy(status.entryUpdated.desc(), status.id.desc()); query.orderBy(STATUS.entryPublished.desc(), STATUS.id.desc());
}
if (offset > -1) {
query.offset(offset);
}
if (limit > -1) {
query.limit(limit);
} }
query.offset(offset).limit(limit);
setTimeout(query, config.getApplicationSettings().getQueryTimeout()); setTimeout(query, config.getApplicationSettings().getQueryTimeout());
List<FeedEntryStatus> statuses = query.fetch(); List<FeedEntryStatus> statuses = query.fetch();
for (FeedEntryStatus status : statuses) { statuses.forEach(s -> s.setMarkable(true));
status = handleStatus(user, status, status.getSubscription(), status.getEntry()); if (includeContent) {
fetchTags(user, status); fetchTags(user, statuses);
} }
return lazyLoadContent(includeContent, statuses);
return statuses;
} }
private JPAQuery<FeedEntry> buildQuery(User user, FeedSubscription sub, boolean unreadOnly, List<FeedEntryKeyword> keywords, public List<FeedEntryStatus> findBySubscriptions(User user, List<FeedSubscription> subs, boolean unreadOnly,
Instant newerThan, int offset, int limit, ReadingOrder order, FeedEntryStatus last, String tag, Long minEntryId, List<FeedEntryKeyword> keywords, Instant newerThan, int offset, int limit, ReadingOrder order, boolean includeContent,
Long maxEntryId) { String tag, Long minEntryId, Long maxEntryId) {
Map<Long, List<FeedSubscription>> subsByFeedId = subs.stream().collect(Collectors.groupingBy(s -> s.getFeed().getId()));
JPAQuery<FeedEntry> query = query().selectFrom(entry).where(entry.feed.eq(sub.getFeed())); JPAQuery<Tuple> query = query().select(ENTRY, STATUS).from(ENTRY);
query.leftJoin(ENTRY.statuses, STATUS).on(STATUS.subscription.in(subs));
query.where(ENTRY.feed.id.in(subsByFeedId.keySet()));
if (includeContent || CollectionUtils.isNotEmpty(keywords)) {
query.join(ENTRY.content, CONTENT).fetchJoin();
}
if (CollectionUtils.isNotEmpty(keywords)) { if (CollectionUtils.isNotEmpty(keywords)) {
query.join(entry.content, content);
for (FeedEntryKeyword keyword : keywords) { for (FeedEntryKeyword keyword : keywords) {
BooleanBuilder or = new BooleanBuilder(); BooleanBuilder or = new BooleanBuilder();
or.or(content.content.containsIgnoreCase(keyword.getKeyword())); or.or(CONTENT.content.containsIgnoreCase(keyword.getKeyword()));
or.or(content.title.containsIgnoreCase(keyword.getKeyword())); or.or(CONTENT.title.containsIgnoreCase(keyword.getKeyword()));
if (keyword.getMode() == Mode.EXCLUDE) { if (keyword.getMode() == Mode.EXCLUDE) {
or.not(); or.not();
} }
query.where(or); query.where(or);
} }
} }
query.leftJoin(entry.statuses, status).on(status.subscription.id.eq(sub.getId()));
if (unreadOnly && tag == null) { if (unreadOnly && tag == null) {
BooleanBuilder or = new BooleanBuilder(); query.where(buildUnreadPredicate());
or.or(status.read.isNull());
or.or(status.read.isFalse());
query.where(or);
Instant unreadThreshold = config.getApplicationSettings().getUnreadThreshold();
if (unreadThreshold != null) {
query.where(entry.updated.goe(unreadThreshold));
}
} }
if (tag != null) { if (tag != null) {
BooleanBuilder and = new BooleanBuilder(); BooleanBuilder and = new BooleanBuilder();
and.and(entryTag.user.id.eq(user.getId())); and.and(TAG.user.id.eq(user.getId()));
and.and(entryTag.name.eq(tag)); and.and(TAG.name.eq(tag));
query.join(entry.tags, entryTag).on(and); query.join(ENTRY.tags, TAG).on(and);
} }
if (newerThan != null) { if (newerThan != null) {
query.where(entry.inserted.goe(newerThan)); query.where(ENTRY.inserted.goe(newerThan));
} }
if (minEntryId != null) { if (minEntryId != null) {
query.where(entry.id.gt(minEntryId)); query.where(ENTRY.id.gt(minEntryId));
} }
if (maxEntryId != null) { if (maxEntryId != null) {
query.where(entry.id.lt(maxEntryId)); query.where(ENTRY.id.lt(maxEntryId));
}
if (last != null) {
if (order == ReadingOrder.desc) {
query.where(entry.updated.gt(last.getEntryUpdated()));
} else {
query.where(entry.updated.lt(last.getEntryUpdated()));
}
} }
if (order != null) { if (order != null) {
if (order == ReadingOrder.asc) { if (order == ReadingOrder.asc) {
query.orderBy(entry.updated.asc(), entry.id.asc()); query.orderBy(ENTRY.published.asc(), ENTRY.id.asc());
} else { } else {
query.orderBy(entry.updated.desc(), entry.id.desc()); query.orderBy(ENTRY.published.desc(), ENTRY.id.desc());
} }
} }
@@ -191,100 +180,58 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
} }
setTimeout(query, config.getApplicationSettings().getQueryTimeout()); setTimeout(query, config.getApplicationSettings().getQueryTimeout());
return query;
}
public List<FeedEntryStatus> findBySubscriptions(User user, List<FeedSubscription> subs, boolean unreadOnly, List<FeedEntryStatus> statuses = new ArrayList<>();
List<FeedEntryKeyword> keywords, Instant newerThan, int offset, int limit, ReadingOrder order, boolean includeContent, List<Tuple> tuples = query.fetch();
boolean onlyIds, String tag, Long minEntryId, Long maxEntryId) { for (Tuple tuple : tuples) {
int capacity = offset + limit; FeedEntry e = tuple.get(ENTRY);
FeedEntryStatus s = tuple.get(STATUS);
Comparator<FeedEntryStatus> comparator = order == ReadingOrder.desc ? STATUS_COMPARATOR_DESC : STATUS_COMPARATOR_ASC; for (FeedSubscription sub : subsByFeedId.get(e.getFeed().getId())) {
statuses.add(handleStatus(user, s, sub, e));
FixedSizeSortedList<FeedEntryStatus> fssl = new FixedSizeSortedList<>(capacity, comparator);
for (FeedSubscription sub : subs) {
FeedEntryStatus last = (order != null && fssl.isFull()) ? fssl.last() : null;
JPAQuery<FeedEntry> query = buildQuery(user, sub, unreadOnly, keywords, newerThan, -1, capacity, order, last, tag, minEntryId,
maxEntryId);
List<Tuple> tuples = query.select(entry.id, entry.updated, status.id, entry.content.title).fetch();
for (Tuple tuple : tuples) {
Long id = tuple.get(entry.id);
Instant updated = tuple.get(entry.updated);
Long statusId = tuple.get(status.id);
FeedEntryContent content = new FeedEntryContent();
content.setTitle(tuple.get(entry.content.title));
FeedEntry entry = new FeedEntry();
entry.setId(id);
entry.setUpdated(updated);
entry.setContent(content);
FeedEntryStatus status = new FeedEntryStatus();
status.setId(statusId);
status.setEntryUpdated(updated);
status.setEntry(entry);
status.setSubscription(sub);
fssl.add(status);
} }
} }
List<FeedEntryStatus> placeholders = fssl.asList(); if (includeContent) {
int size = placeholders.size(); fetchTags(user, statuses);
if (size < offset) {
return new ArrayList<>();
} }
placeholders = placeholders.subList(Math.max(offset, 0), size);
List<FeedEntryStatus> statuses;
if (onlyIds) {
statuses = placeholders;
} else {
statuses = new ArrayList<>();
for (FeedEntryStatus placeholder : placeholders) {
Long statusId = placeholder.getId();
FeedEntry entry = feedEntryDAO.findById(placeholder.getEntry().getId());
FeedEntryStatus status = handleStatus(user, statusId == null ? null : findById(statusId), placeholder.getSubscription(),
entry);
status = fetchTags(user, status);
statuses.add(status);
}
statuses = lazyLoadContent(includeContent, statuses);
}
return statuses; return statuses;
} }
public UnreadCount getUnreadCount(User user, FeedSubscription subscription) { public UnreadCount getUnreadCount(FeedSubscription sub) {
UnreadCount uc = null; JPAQuery<Tuple> query = query().select(ENTRY.count(), ENTRY.published.max())
JPAQuery<FeedEntry> query = buildQuery(user, subscription, true, null, null, -1, -1, null, null, null, null, null); .from(ENTRY)
List<Tuple> tuples = query.select(entry.count(), entry.updated.max()).fetch(); .leftJoin(ENTRY.statuses, STATUS)
for (Tuple tuple : tuples) { .on(STATUS.subscription.eq(sub))
Long count = tuple.get(entry.count()); .where(ENTRY.feed.eq(sub.getFeed()))
Instant updated = tuple.get(entry.updated.max()); .where(buildUnreadPredicate());
uc = new UnreadCount(subscription.getId(), count == null ? 0 : count, updated);
} Tuple tuple = query.fetchOne();
return uc; Long count = tuple.get(ENTRY.count());
Instant published = tuple.get(ENTRY.published.max());
return new UnreadCount(sub.getId(), count == null ? 0 : count, published);
} }
private List<FeedEntryStatus> lazyLoadContent(boolean includeContent, List<FeedEntryStatus> results) { private BooleanBuilder buildUnreadPredicate() {
if (includeContent) { BooleanBuilder or = new BooleanBuilder();
for (FeedEntryStatus status : results) { or.or(STATUS.read.isNull());
Models.initialize(status.getSubscription().getFeed()); or.or(STATUS.read.isFalse());
Models.initialize(status.getEntry().getContent());
} Instant unreadThreshold = config.getApplicationSettings().getUnreadThreshold();
if (unreadThreshold != null) {
return or.and(ENTRY.published.goe(unreadThreshold));
} else {
return or;
} }
return results;
} }
public long deleteOldStatuses(Instant olderThan, int limit) { public long deleteOldStatuses(Instant olderThan, int limit) {
List<Long> ids = query().select(status.id) List<Long> ids = query().select(STATUS.id)
.from(status) .from(STATUS)
.where(status.entryInserted.lt(olderThan), status.starred.isFalse()) .where(STATUS.entryInserted.lt(olderThan), STATUS.starred.isFalse())
.limit(limit) .limit(limit)
.fetch(); .fetch();
return deleteQuery(status).where(status.id.in(ids)).execute(); return deleteQuery(STATUS).where(STATUS.id.in(ids)).execute();
} }
} }

View File

@@ -1,6 +1,8 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.hibernate.SessionFactory; import org.hibernate.SessionFactory;
@@ -15,7 +17,7 @@ import jakarta.inject.Singleton;
@Singleton @Singleton
public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> { public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> {
private final QFeedEntryTag tag = QFeedEntryTag.feedEntryTag; private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag;
@Inject @Inject
public FeedEntryTagDAO(SessionFactory sessionFactory) { public FeedEntryTagDAO(SessionFactory sessionFactory) {
@@ -23,10 +25,18 @@ public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> {
} }
public List<String> findByUser(User user) { public List<String> findByUser(User user) {
return query().selectDistinct(tag.name).from(tag).where(tag.user.eq(user)).fetch(); return query().selectDistinct(TAG.name).from(TAG).where(TAG.user.eq(user)).fetch();
} }
public List<FeedEntryTag> findByEntry(User user, FeedEntry entry) { public List<FeedEntryTag> findByEntry(User user, FeedEntry entry) {
return query().selectFrom(tag).where(tag.user.eq(user), tag.entry.eq(entry)).fetch(); return query().selectFrom(TAG).where(TAG.user.eq(user), TAG.entry.eq(entry)).fetch();
}
public Map<Long, List<FeedEntryTag>> findByEntries(User user, List<FeedEntry> entries) {
return query().selectFrom(TAG)
.where(TAG.user.eq(user), TAG.entry.in(entries))
.fetch()
.stream()
.collect(Collectors.groupingBy(t -> t.getEntry().getId()));
} }
} }

View File

@@ -2,9 +2,16 @@ package com.commafeed.backend.dao;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.hibernate.SessionFactory; import org.hibernate.SessionFactory;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.event.service.spi.EventListenerRegistry;
import org.hibernate.event.spi.EventType;
import org.hibernate.event.spi.PostInsertEvent;
import org.hibernate.event.spi.PostInsertEventListener;
import org.hibernate.persister.entity.EntityPersister;
import com.commafeed.backend.model.AbstractModel; import com.commafeed.backend.model.AbstractModel;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
@@ -22,54 +29,79 @@ import jakarta.inject.Singleton;
@Singleton @Singleton
public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> { public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
private final QFeedSubscription sub = QFeedSubscription.feedSubscription; private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription;
private final SessionFactory sessionFactory;
@Inject @Inject
public FeedSubscriptionDAO(SessionFactory sessionFactory) { public FeedSubscriptionDAO(SessionFactory sessionFactory) {
super(sessionFactory); super(sessionFactory);
this.sessionFactory = sessionFactory;
}
public void onPostCommitInsert(Consumer<FeedSubscription> consumer) {
sessionFactory.unwrap(SessionFactoryImplementor.class)
.getServiceRegistry()
.getService(EventListenerRegistry.class)
.getEventListenerGroup(EventType.POST_COMMIT_INSERT)
.appendListener(new PostInsertEventListener() {
@Override
public void onPostInsert(PostInsertEvent event) {
if (event.getEntity() instanceof FeedSubscription s) {
consumer.accept(s);
}
}
@Override
public boolean requiresPostCommitHandling(EntityPersister persister) {
return true;
}
});
} }
public FeedSubscription findById(User user, Long id) { public FeedSubscription findById(User user, Long id) {
List<FeedSubscription> subs = query().selectFrom(sub) List<FeedSubscription> subs = query().selectFrom(SUBSCRIPTION)
.where(sub.user.eq(user), sub.id.eq(id)) .where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.id.eq(id))
.leftJoin(sub.feed) .leftJoin(SUBSCRIPTION.feed)
.fetchJoin() .fetchJoin()
.leftJoin(sub.category) .leftJoin(SUBSCRIPTION.category)
.fetchJoin() .fetchJoin()
.fetch(); .fetch();
return initRelations(Iterables.getFirst(subs, null)); return initRelations(Iterables.getFirst(subs, null));
} }
public List<FeedSubscription> findByFeed(Feed feed) { public List<FeedSubscription> findByFeed(Feed feed) {
return query().selectFrom(sub).where(sub.feed.eq(feed)).fetch(); return query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.feed.eq(feed)).fetch();
} }
public FeedSubscription findByFeed(User user, Feed feed) { public FeedSubscription findByFeed(User user, Feed feed) {
List<FeedSubscription> subs = query().selectFrom(sub).where(sub.user.eq(user), sub.feed.eq(feed)).fetch(); List<FeedSubscription> subs = query().selectFrom(SUBSCRIPTION)
.where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.feed.eq(feed))
.fetch();
return initRelations(Iterables.getFirst(subs, null)); return initRelations(Iterables.getFirst(subs, null));
} }
public List<FeedSubscription> findAll(User user) { public List<FeedSubscription> findAll(User user) {
List<FeedSubscription> subs = query().selectFrom(sub) List<FeedSubscription> subs = query().selectFrom(SUBSCRIPTION)
.where(sub.user.eq(user)) .where(SUBSCRIPTION.user.eq(user))
.leftJoin(sub.feed) .leftJoin(SUBSCRIPTION.feed)
.fetchJoin() .fetchJoin()
.leftJoin(sub.category) .leftJoin(SUBSCRIPTION.category)
.fetchJoin() .fetchJoin()
.fetch(); .fetch();
return initRelations(subs); return initRelations(subs);
} }
public Long count(User user) { public Long count(User user) {
return query().select(sub.count()).from(sub).where(sub.user.eq(user)).fetchOne(); return query().select(SUBSCRIPTION.count()).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 = query().selectFrom(sub).where(sub.user.eq(user)); JPQLQuery<FeedSubscription> query = query().selectFrom(SUBSCRIPTION).where(SUBSCRIPTION.user.eq(user));
if (category == null) { if (category == null) {
query.where(sub.category.isNull()); query.where(SUBSCRIPTION.category.isNull());
} else { } else {
query.where(sub.category.eq(category)); query.where(SUBSCRIPTION.category.eq(category));
} }
return initRelations(query.fetch()); return initRelations(query.fetch());
} }

View File

@@ -11,7 +11,7 @@ import jakarta.inject.Singleton;
@Singleton @Singleton
public class UserDAO extends GenericDAO<User> { public class UserDAO extends GenericDAO<User> {
private final QUser user = QUser.user; private static final QUser USER = QUser.user;
@Inject @Inject
public UserDAO(SessionFactory sessionFactory) { public UserDAO(SessionFactory sessionFactory) {
@@ -19,18 +19,18 @@ public class UserDAO extends GenericDAO<User> {
} }
public User findByName(String name) { public User findByName(String name) {
return query().selectFrom(user).where(user.name.equalsIgnoreCase(name)).fetchOne(); return query().selectFrom(USER).where(USER.name.equalsIgnoreCase(name)).fetchOne();
} }
public User findByApiKey(String key) { public User findByApiKey(String key) {
return query().selectFrom(user).where(user.apiKey.equalsIgnoreCase(key)).fetchOne(); return query().selectFrom(USER).where(USER.apiKey.equalsIgnoreCase(key)).fetchOne();
} }
public User findByEmail(String email) { public User findByEmail(String email) {
return query().selectFrom(user).where(user.email.equalsIgnoreCase(email)).fetchOne(); return query().selectFrom(USER).where(USER.email.equalsIgnoreCase(email)).fetchOne();
} }
public long count() { public long count() {
return query().select(user.count()).from(user).fetchOne(); return query().select(USER.count()).from(USER).fetchOne();
} }
} }

View File

@@ -17,7 +17,7 @@ import jakarta.inject.Singleton;
@Singleton @Singleton
public class UserRoleDAO extends GenericDAO<UserRole> { public class UserRoleDAO extends GenericDAO<UserRole> {
private final QUserRole role = QUserRole.userRole; private static final QUserRole ROLE = QUserRole.userRole;
@Inject @Inject
public UserRoleDAO(SessionFactory sessionFactory) { public UserRoleDAO(SessionFactory sessionFactory) {
@@ -25,11 +25,11 @@ public class UserRoleDAO extends GenericDAO<UserRole> {
} }
public List<UserRole> findAll() { public List<UserRole> findAll() {
return query().selectFrom(role).leftJoin(role.user).fetchJoin().distinct().fetch(); return query().selectFrom(ROLE).leftJoin(ROLE.user).fetchJoin().distinct().fetch();
} }
public List<UserRole> findAll(User user) { public List<UserRole> findAll(User user) {
return query().selectFrom(role).where(role.user.eq(user)).distinct().fetch(); return query().selectFrom(ROLE).where(ROLE.user.eq(user)).distinct().fetch();
} }
public Set<Role> findRoles(User user) { public Set<Role> findRoles(User user) {

View File

@@ -12,7 +12,7 @@ import jakarta.inject.Singleton;
@Singleton @Singleton
public class UserSettingsDAO extends GenericDAO<UserSettings> { public class UserSettingsDAO extends GenericDAO<UserSettings> {
private final QUserSettings settings = QUserSettings.userSettings; private static final QUserSettings SETTINGS = QUserSettings.userSettings;
@Inject @Inject
public UserSettingsDAO(SessionFactory sessionFactory) { public UserSettingsDAO(SessionFactory sessionFactory) {
@@ -20,6 +20,6 @@ public class UserSettingsDAO extends GenericDAO<UserSettings> {
} }
public UserSettings findByUser(User user) { public UserSettings findByUser(User user) {
return query().selectFrom(settings).where(settings.user.eq(user)).fetchFirst(); return query().selectFrom(SETTINGS).where(SETTINGS.user.eq(user)).fetchFirst();
} }
} }

View File

@@ -1,6 +1,5 @@
package com.commafeed.backend.feed; package com.commafeed.backend.feed;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
@@ -103,9 +102,12 @@ public class FeedRefreshUpdater {
if (locked1 && locked2) { if (locked1 && locked2) {
processed = true; processed = true;
inserted = unitOfWork.call(() -> { inserted = unitOfWork.call(() -> {
Instant now = Instant.now(); boolean newEntry = false;
FeedEntry feedEntry = feedEntryService.findOrCreate(feed, entry); FeedEntry feedEntry = feedEntryService.find(feed, entry);
boolean newEntry = !feedEntry.getInserted().isBefore(now); if (feedEntry == null) {
feedEntry = feedEntryService.create(feed, entry);
newEntry = true;
}
if (newEntry) { if (newEntry) {
entryInserted.mark(); entryInserted.mark();
for (FeedSubscription sub : subscriptions) { for (FeedSubscription sub : subscriptions) {
@@ -118,10 +120,10 @@ public class FeedRefreshUpdater {
return newEntry; return newEntry;
}); });
} else { } else {
log.error("lock timeout for " + feed.getUrl() + " - " + key1); log.error("lock timeout for {} - {}", feed.getUrl(), key1);
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
log.error("interrupted while waiting for lock for " + feed.getUrl() + " : " + e.getMessage(), e); log.error("interrupted while waiting for lock for {} : {}", feed.getUrl(), e.getMessage(), e);
} finally { } finally {
if (locked1) { if (locked1) {
lock1.unlock(); lock1.unlock();
@@ -168,11 +170,10 @@ public class FeedRefreshUpdater {
if (subscriptions == null) { if (subscriptions == null) {
feed.setMessage("No new entries found"); feed.setMessage("No new entries found");
} else if (inserted > 0) { } else if (inserted > 0) {
log.debug("inserted {} entries for feed {}", inserted, feed.getId());
List<User> users = subscriptions.stream().map(FeedSubscription::getUser).toList(); List<User> users = subscriptions.stream().map(FeedSubscription::getUser).toList();
cache.invalidateUnreadCount(subscriptions.toArray(new FeedSubscription[0])); cache.invalidateUnreadCount(subscriptions.toArray(new FeedSubscription[0]));
cache.invalidateUserRootCategory(users.toArray(new User[0])); cache.invalidateUserRootCategory(users.toArray(new User[0]));
notifyOverWebsocket(unreadCountBySubscription);
} }
} }
@@ -187,6 +188,8 @@ public class FeedRefreshUpdater {
unitOfWork.run(() -> feedService.save(feed)); unitOfWork.run(() -> feedService.save(feed));
notifyOverWebsocket(unreadCountBySubscription);
return processed; return processed;
} }

View File

@@ -59,7 +59,7 @@ public class FeedRefreshWorker {
Integer maxEntriesAgeDays = config.getApplicationSettings().getMaxEntriesAgeDays(); Integer maxEntriesAgeDays = config.getApplicationSettings().getMaxEntriesAgeDays();
if (maxEntriesAgeDays > 0) { if (maxEntriesAgeDays > 0) {
Instant threshold = Instant.now().minus(Duration.ofDays(maxEntriesAgeDays)); Instant threshold = Instant.now().minus(Duration.ofDays(maxEntriesAgeDays));
entries = entries.stream().filter(entry -> entry.updated().isAfter(threshold)).toList(); entries = entries.stream().filter(entry -> entry.published().isAfter(threshold)).toList();
} }
String urlAfterRedirect = result.urlAfterRedirect(); String urlAfterRedirect = result.urlAfterRedirect();

View File

@@ -139,7 +139,7 @@ public class FeedUtils {
try { try {
return new URL(new URL(baseUrl), relativeUrl).toString(); return new URL(new URL(baseUrl), relativeUrl).toString();
} catch (MalformedURLException e) { } catch (MalformedURLException e) {
log.debug("could not parse url : " + e.getMessage(), e); log.debug("could not parse url : {}", e.getMessage(), e);
return null; return null;
} }
} }

View File

@@ -73,7 +73,7 @@ public class FeedParser {
String title = feed.getTitle(); String title = feed.getTitle();
String link = feed.getLink(); String link = feed.getLink();
List<Entry> entries = buildEntries(feed, feedUrl); List<Entry> entries = buildEntries(feed, feedUrl);
Instant lastEntryDate = entries.stream().findFirst().map(Entry::updated).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 || lastEntryDate != null && lastPublishedDate.isBefore(lastEntryDate)) { if (lastPublishedDate == null || lastEntryDate != null && lastPublishedDate.isBefore(lastEntryDate)) {
lastPublishedDate = lastEntryDate; lastPublishedDate = lastEntryDate;
@@ -123,13 +123,13 @@ public class FeedParser {
url = guid; url = guid;
} }
Instant updated = buildEntryUpdateDate(item); Instant publishedDate = buildEntryPublishedDate(item);
Content content = buildContent(item); Content content = buildContent(item);
entries.add(new Entry(guid, url, updated, content)); entries.add(new Entry(guid, url, publishedDate, content));
} }
entries.sort(Comparator.comparing(Entry::updated).reversed()); entries.sort(Comparator.comparing(Entry::published).reversed());
return entries; return entries;
} }
@@ -154,10 +154,10 @@ public class FeedParser {
return new Enclosure(enclosure.getUrl(), enclosure.getType()); return new Enclosure(enclosure.getUrl(), enclosure.getType());
} }
private Instant buildEntryUpdateDate(SyndEntry item) { private Instant buildEntryPublishedDate(SyndEntry item) {
Date date = item.getUpdatedDate(); Date date = item.getPublishedDate();
if (date == null) { if (date == null) {
date = item.getPublishedDate(); date = item.getUpdatedDate();
} }
return toValidInstant(date, true); return toValidInstant(date, true);
} }
@@ -262,7 +262,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 = Math.abs(entries.get(i).updated().toEpochMilli() - entries.get(i + 1).updated().toEpochMilli()); long diff = Math.abs(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();

View File

@@ -5,7 +5,7 @@ import java.util.List;
public record FeedParserResult(String title, String link, Instant lastPublishedDate, Long averageEntryInterval, Instant lastEntryDate, public record FeedParserResult(String title, String link, Instant lastPublishedDate, Long averageEntryInterval, Instant lastEntryDate,
List<Entry> entries) { List<Entry> entries) {
public record Entry(String guid, String url, Instant updated, Content content) { public record Entry(String guid, String url, Instant published, Content content) {
} }
public record Content(String title, String content, String author, String categories, Enclosure enclosure, Media media) { public record Content(String title, String content, String author, String categories, Enclosure enclosure, Media media) {

View File

@@ -37,11 +37,18 @@ public class FeedEntry extends AbstractModel {
@Column(length = 2048) @Column(length = 2048)
private String url; private String url;
/**
* the moment the entry was inserted in the database
*/
@Column @Column
private Instant inserted; private Instant inserted;
@Column /**
private Instant updated; * the moment the entry was published in the feed
*
*/
@Column(name = "updated")
private Instant published;
@OneToMany(mappedBy = "entry", cascade = CascadeType.REMOVE) @OneToMany(mappedBy = "entry", cascade = CascadeType.REMOVE)
private Set<FeedEntryStatus> statuses; private Set<FeedEntryStatus> statuses;

View File

@@ -50,8 +50,8 @@ public class FeedEntryStatus extends AbstractModel {
@Column @Column
private Instant entryInserted; private Instant entryInserted;
@Column @Column(name = "entryUpdated")
private Instant entryUpdated; private Instant entryPublished;
public FeedEntryStatus() { public FeedEntryStatus() {
@@ -62,7 +62,7 @@ public class FeedEntryStatus extends AbstractModel {
this.subscription = subscription; this.subscription = subscription;
this.entry = entry; this.entry = entry;
this.entryInserted = entry.getInserted(); this.entryInserted = entry.getInserted();
this.entryUpdated = entry.getUpdated(); this.entryPublished = entry.getPublished();
} }
} }

View File

@@ -35,13 +35,9 @@ public class RSSRDF10Parser extends RSS10Parser {
ok = defaultNS != null && defaultNS.equals(getRDFNamespace()); ok = defaultNS != null && defaultNS.equals(getRDFNamespace());
if (ok) { if (ok) {
if (additionalNSs == null) { ok = false;
ok = false; for (int i = 0; !ok && i < additionalNSs.size(); i++) {
} else { ok = getRSSNamespace().equals(additionalNSs.get(i));
ok = false;
for (int i = 0; !ok && i < additionalNSs.size(); i++) {
ok = getRSSNamespace().equals(additionalNSs.get(i));
}
} }
} }
return ok; return ok;

View File

@@ -35,18 +35,21 @@ public class FeedEntryService {
private final FeedEntryFilteringService feedEntryFilteringService; private final FeedEntryFilteringService feedEntryFilteringService;
private final CacheService cache; private final CacheService cache;
/** public FeedEntry find(Feed feed, Entry entry) {
* this is NOT thread-safe
*/
public FeedEntry findOrCreate(Feed feed, Entry entry) {
String guid = FeedUtils.truncate(entry.guid(), 2048);
String guidHash = Digests.sha1Hex(entry.guid()); String guidHash = Digests.sha1Hex(entry.guid());
FeedEntry existing = feedEntryDAO.findExisting(guidHash, feed); return feedEntryDAO.findExisting(guidHash, feed);
if (existing != null) { }
return existing;
} public FeedEntry create(Feed feed, Entry entry) {
FeedEntry feedEntry = new FeedEntry();
feedEntry.setGuid(FeedUtils.truncate(entry.guid(), 2048));
feedEntry.setGuidHash(Digests.sha1Hex(entry.guid()));
feedEntry.setUrl(FeedUtils.truncate(entry.url(), 2048));
feedEntry.setPublished(entry.published());
feedEntry.setInserted(Instant.now());
feedEntry.setFeed(feed);
feedEntry.setContent(feedEntryContentService.findOrCreate(entry.content(), feed.getLink()));
FeedEntry feedEntry = buildEntry(feed, entry, guid, guidHash);
feedEntryDAO.saveOrUpdate(feedEntry); feedEntryDAO.saveOrUpdate(feedEntry);
return feedEntry; return feedEntry;
} }
@@ -68,19 +71,6 @@ public class FeedEntryService {
return matches; return matches;
} }
private FeedEntry buildEntry(Feed feed, Entry e, String guid, String guidHash) {
FeedEntry entry = new FeedEntry();
entry.setGuid(guid);
entry.setGuidHash(guidHash);
entry.setUrl(FeedUtils.truncate(e.url(), 2048));
entry.setUpdated(e.updated());
entry.setInserted(Instant.now());
entry.setFeed(feed);
entry.setContent(feedEntryContentService.findOrCreate(e.content(), feed.getLink()));
return entry;
}
public void markEntry(User user, Long entryId, boolean read) { public void markEntry(User user, Long entryId, boolean read) {
FeedEntry entry = feedEntryDAO.findById(entryId); FeedEntry entry = feedEntryDAO.findById(entryId);
if (entry == null) { if (entry == null) {
@@ -121,7 +111,7 @@ public class FeedEntryService {
public void markSubscriptionEntries(User user, List<FeedSubscription> subscriptions, Instant olderThan, Instant insertedBefore, public void markSubscriptionEntries(User user, List<FeedSubscription> subscriptions, Instant olderThan, Instant insertedBefore,
List<FeedEntryKeyword> keywords) { List<FeedEntryKeyword> keywords) {
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, keywords, null, -1, -1, null, List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, keywords, null, -1, -1, null,
false, false, null, null, null); false, null, null, null);
markList(statuses, olderThan, insertedBefore); markList(statuses, olderThan, insertedBefore);
cache.invalidateUnreadCount(subscriptions.toArray(new FeedSubscription[0])); cache.invalidateUnreadCount(subscriptions.toArray(new FeedSubscription[0]));
cache.invalidateUserRootCategory(user); cache.invalidateUserRootCategory(user);
@@ -133,8 +123,8 @@ public class FeedEntryService {
} }
private void markList(List<FeedEntryStatus> statuses, Instant olderThan, Instant insertedBefore) { private void markList(List<FeedEntryStatus> statuses, Instant olderThan, Instant insertedBefore) {
List<FeedEntryStatus> statusesToMark = statuses.stream().filter(s -> { List<FeedEntryStatus> statusesToMark = statuses.stream().filter(FeedEntryStatus::isMarkable).filter(s -> {
Instant entryDate = s.getEntry().getUpdated(); Instant entryDate = s.getEntry().getPublished();
return olderThan == null || entryDate == null || entryDate.isBefore(olderThan); return olderThan == null || entryDate == null || entryDate.isBefore(olderThan);
}).filter(s -> { }).filter(s -> {
Instant insertedDate = s.getEntry().getInserted(); Instant insertedDate = s.getEntry().getInserted();

View File

@@ -23,11 +23,9 @@ import com.commafeed.frontend.model.UnreadCount;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Singleton @Singleton
public class FeedSubscriptionService { public class FeedSubscriptionService {
@@ -39,6 +37,22 @@ public class FeedSubscriptionService {
private final CacheService cache; private final CacheService cache;
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;
@Inject
public FeedSubscriptionService(FeedDAO feedDAO, FeedEntryStatusDAO feedEntryStatusDAO, FeedSubscriptionDAO feedSubscriptionDAO,
FeedService feedService, FeedRefreshEngine feedRefreshEngine, CacheService cache, CommaFeedConfiguration config) {
this.feedDAO = feedDAO;
this.feedEntryStatusDAO = feedEntryStatusDAO;
this.feedSubscriptionDAO = feedSubscriptionDAO;
this.feedService = feedService;
this.feedRefreshEngine = feedRefreshEngine;
this.cache = cache;
this.config = config;
// automatically refresh feeds after they are subscribed to
// we need to use this hook because the feed needs to have been persisted because the queue processing is asynchronous
feedSubscriptionDAO.onPostCommitInsert(sub -> feedRefreshEngine.refreshImmediately(sub.getFeed()));
}
public long subscribe(User user, String url, String title) { public long subscribe(User user, String url, String title) {
return subscribe(user, url, title, null, 0); return subscribe(user, url, title, null, 0);
} }
@@ -83,7 +97,6 @@ public class FeedSubscriptionService {
sub.setTitle(FeedUtils.truncate(title, 128)); sub.setTitle(FeedUtils.truncate(title, 128));
feedSubscriptionDAO.saveOrUpdate(sub); feedSubscriptionDAO.saveOrUpdate(sub);
feedRefreshEngine.refreshImmediately(feed);
cache.invalidateUserRootCategory(user); cache.invalidateUserRootCategory(user);
return sub.getId(); return sub.getId();
} }
@@ -119,14 +132,14 @@ public class FeedSubscriptionService {
} }
public Map<Long, UnreadCount> getUnreadCount(User user) { public Map<Long, UnreadCount> getUnreadCount(User user) {
return feedSubscriptionDAO.findAll(user).stream().collect(Collectors.toMap(FeedSubscription::getId, s -> getUnreadCount(user, s))); return feedSubscriptionDAO.findAll(user).stream().collect(Collectors.toMap(FeedSubscription::getId, this::getUnreadCount));
} }
private UnreadCount getUnreadCount(User user, FeedSubscription sub) { private UnreadCount getUnreadCount(FeedSubscription sub) {
UnreadCount count = cache.getUnreadCount(sub); UnreadCount count = cache.getUnreadCount(sub);
if (count == null) { if (count == null) {
log.debug("unread count cache miss for {}", Models.getId(sub)); log.debug("unread count cache miss for {}", Models.getId(sub));
count = feedEntryStatusDAO.getUnreadCount(user, sub); count = feedEntryStatusDAO.getUnreadCount(sub);
cache.setUnreadCount(sub, count); cache.setUnreadCount(sub, count);
} }
return count; return count;

View File

@@ -17,6 +17,8 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
public class H2MigrationService { public class H2MigrationService {
private static final String H2_FILE_SUFFIX = ".mv.db";
public void migrateIfNeeded(Path path, String user, String password) { public void migrateIfNeeded(Path path, String user, String password) {
if (Files.notExists(path)) { if (Files.notExists(path)) {
return; return;
@@ -54,9 +56,10 @@ public class H2MigrationService {
private void migrate(Path path, String user, String password, String fromVersion, String toVersion) throws Exception { private void migrate(Path path, String user, String password, String fromVersion, String toVersion) throws Exception {
log.info("migrating H2 database at {} from format {} to format {}", path, fromVersion, toVersion); log.info("migrating H2 database at {} from format {} to format {}", path, fromVersion, toVersion);
Path scriptPath = path.resolveSibling("script-" + System.currentTimeMillis() + ".sql"); Path scriptPath = path.resolveSibling("script-%d.sql".formatted(System.currentTimeMillis()));
Path newVersionPath = path.resolveSibling(path.getFileName() + "." + getPatchVersion(toVersion) + ".mv.db"); Path newVersionPath = path.resolveSibling("%s.%s%s".formatted(StringUtils.removeEnd(path.getFileName().toString(), H2_FILE_SUFFIX),
Path oldVersionBackupPath = path.resolveSibling(path.getFileName() + "." + getPatchVersion(fromVersion) + ".backup"); getPatchVersion(toVersion), H2_FILE_SUFFIX));
Path oldVersionBackupPath = path.resolveSibling("%s.%s.backup".formatted(path.getFileName(), getPatchVersion(fromVersion)));
Files.deleteIfExists(scriptPath); Files.deleteIfExists(scriptPath);
Files.deleteIfExists(newVersionPath); Files.deleteIfExists(newVersionPath);

View File

@@ -3,11 +3,9 @@ package com.commafeed.backend.service.internal;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.dao.UnitOfWork; import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.dao.UserDAO; import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import com.commafeed.backend.service.FeedSubscriptionService;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
@@ -18,9 +16,7 @@ import lombok.RequiredArgsConstructor;
public class PostLoginActivities { public class PostLoginActivities {
private final UserDAO userDAO; private final UserDAO userDAO;
private final FeedSubscriptionService feedSubscriptionService;
private final UnitOfWork unitOfWork; private final UnitOfWork unitOfWork;
private final CommaFeedConfiguration config;
public void executeFor(User user) { public void executeFor(User user) {
// only update lastLogin every once in a while in order to avoid invalidating the cache every time someone logs in // only update lastLogin every once in a while in order to avoid invalidating the cache every time someone logs in
@@ -28,19 +24,6 @@ public class PostLoginActivities {
Instant lastLogin = user.getLastLogin(); Instant lastLogin = user.getLastLogin();
if (lastLogin == null || ChronoUnit.MINUTES.between(lastLogin, now) >= 30) { if (lastLogin == null || ChronoUnit.MINUTES.between(lastLogin, now) >= 30) {
user.setLastLogin(now); user.setLastLogin(now);
boolean heavyLoad = Boolean.TRUE.equals(config.getApplicationSettings().getHeavyLoad());
if (heavyLoad) {
// the amount of feeds in the database that are up for refresh might be very large since we're in heavy load mode
// the feed refresh engine might not be able to catch up quickly enough
// put feeds from online users that are up for refresh at the top of the queue
feedSubscriptionService.refreshAllUpForRefresh(user);
}
// Post login activites are susceptible to run for any webservice call.
// We update the user in a new transaction to update the user immediately.
// If we didn't and the webservice call takes time, subsequent webservice calls would have to wait for the first call to
// finish even if they didn't use the same database tables, because they updated the user too.
unitOfWork.run(() -> userDAO.saveOrUpdate(user)); unitOfWork.run(() -> userDAO.saveOrUpdate(user));
} }
} }

View File

@@ -115,7 +115,7 @@ public class Entry implements Serializable {
entry.setRead(status.isRead()); entry.setRead(status.isRead());
entry.setStarred(status.isStarred()); entry.setStarred(status.isStarred());
entry.setMarkable(status.isMarkable()); entry.setMarkable(status.isMarkable());
entry.setDate(feedEntry.getUpdated()); entry.setDate(feedEntry.getPublished());
entry.setInsertedDate(feedEntry.getInserted()); entry.setInsertedDate(feedEntry.getInserted());
entry.setUrl(feedEntry.getUrl()); entry.setUrl(feedEntry.getUrl());
entry.setFeedName(sub.getTitle()); entry.setFeedName(sub.getTitle());

View File

@@ -109,7 +109,6 @@ public class CategoryREST {
@Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, @Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
@Parameter( @Parameter(
description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords, description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords,
@Parameter(description = "return only entry ids") @DefaultValue("false") @QueryParam("onlyIds") boolean onlyIds,
@Parameter( @Parameter(
description = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds, description = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds,
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) { @Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) {
@@ -143,7 +142,7 @@ public class CategoryREST {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user); List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
removeExcludedSubscriptions(subs, excludedIds); removeExcludedSubscriptions(subs, excludedIds);
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate, List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate,
offset, limit + 1, order, true, onlyIds, tag, null, null); offset, limit + 1, order, true, tag, null, null);
for (FeedEntryStatus status : list) { for (FeedEntryStatus status : list) {
entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled())); entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled()));
@@ -151,7 +150,7 @@ public class CategoryREST {
} else if (STARRED.equals(id)) { } else if (STARRED.equals(id)) {
entries.setName("Starred"); entries.setName("Starred");
List<FeedEntryStatus> starred = feedEntryStatusDAO.findStarred(user, newerThanDate, offset, limit + 1, order, !onlyIds); List<FeedEntryStatus> starred = feedEntryStatusDAO.findStarred(user, newerThanDate, offset, limit + 1, order, true);
for (FeedEntryStatus status : starred) { for (FeedEntryStatus status : starred) {
entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled())); entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled()));
} }
@@ -162,7 +161,7 @@ public class CategoryREST {
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories); List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories);
removeExcludedSubscriptions(subs, excludedIds); removeExcludedSubscriptions(subs, excludedIds);
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate, List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate,
offset, limit + 1, order, true, onlyIds, tag, null, null); offset, limit + 1, order, true, tag, null, null);
for (FeedEntryStatus status : list) { for (FeedEntryStatus status : list) {
entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled())); entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled()));
@@ -202,13 +201,11 @@ public class CategoryREST {
@Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, @Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
@Parameter( @Parameter(
description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords, description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords,
@Parameter(description = "return only entry ids") @DefaultValue("false") @QueryParam("onlyIds") boolean onlyIds,
@Parameter( @Parameter(
description = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds, description = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds,
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) { @Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) {
Response response = getCategoryEntries(user, id, readType, newerThan, offset, limit, order, keywords, onlyIds, Response response = getCategoryEntries(user, id, readType, newerThan, offset, limit, order, keywords, excludedSubscriptionIds, tag);
excludedSubscriptionIds, tag);
if (response.getStatus() != Status.OK.getStatusCode()) { if (response.getStatus() != Status.OK.getStatusCode()) {
return response; return response;
} }

View File

@@ -143,10 +143,8 @@ public class FeedREST {
@Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan, @Parameter(description = "only entries newer than this") @QueryParam("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") @DefaultValue("20") @QueryParam("limit") int limit, @Parameter(description = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit,
@Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, @Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, @Parameter(
@Parameter( description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords) {
description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords,
@Parameter(description = "return only entry ids") @DefaultValue("false") @QueryParam("onlyIds") boolean onlyIds) {
Preconditions.checkNotNull(id); Preconditions.checkNotNull(id);
Preconditions.checkNotNull(readType); Preconditions.checkNotNull(readType);
@@ -174,7 +172,7 @@ public class FeedREST {
entries.setFeedLink(subscription.getFeed().getLink()); entries.setFeedLink(subscription.getFeed().getLink());
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, Collections.singletonList(subscription), unreadOnly, List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, Collections.singletonList(subscription), unreadOnly,
entryKeywords, newerThanDate, offset, limit + 1, order, true, onlyIds, null, null, null); entryKeywords, newerThanDate, offset, limit + 1, order, true, null, null, null);
for (FeedEntryStatus status : list) { for (FeedEntryStatus status : list) {
entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled())); entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled()));
@@ -209,12 +207,10 @@ public class FeedREST {
@Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan, @Parameter(description = "only entries newer than this") @QueryParam("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") @DefaultValue("20") @QueryParam("limit") int limit, @Parameter(description = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit,
@Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, @Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, @Parameter(
@Parameter( description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords) {
description = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords,
@Parameter(description = "return only entry ids") @DefaultValue("false") @QueryParam("onlyIds") boolean onlyIds) {
Response response = getFeedEntries(user, id, readType, newerThan, offset, limit, order, keywords, onlyIds); Response response = getFeedEntries(user, id, readType, newerThan, offset, limit, order, keywords);
if (response.getStatus() != Status.OK.getStatusCode()) { if (response.getStatus() != Status.OK.getStatusCode()) {
return response; return response;
} }

View File

@@ -210,7 +210,7 @@ public class FeverREST {
.map(Feed::getLastUpdated) .map(Feed::getLastUpdated)
.filter(Objects::nonNull) .filter(Objects::nonNull)
.max(Comparator.naturalOrder()) .max(Comparator.naturalOrder())
.map(d -> d.getEpochSecond()) .map(Instant::getEpochSecond)
.orElse(0L); .orElse(0L);
} }
@@ -253,7 +253,7 @@ public class FeverREST {
private List<Long> buildUnreadItemIds(User user, List<FeedSubscription> subscriptions) { private List<Long> buildUnreadItemIds(User user, List<FeedSubscription> subscriptions) {
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, null, null, 0, List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, null, null, 0,
UNREAD_ITEM_IDS_BATCH_SIZE, ReadingOrder.desc, false, true, null, null, null); UNREAD_ITEM_IDS_BATCH_SIZE, ReadingOrder.desc, false, null, null, null);
return statuses.stream().map(s -> s.getEntry().getId()).toList(); return statuses.stream().map(s -> s.getEntry().getId()).toList();
} }
@@ -281,7 +281,7 @@ public class FeverREST {
private List<FeverItem> buildItems(User user, List<FeedSubscription> subscriptions, Long sinceId, Long maxId) { private List<FeverItem> buildItems(User user, List<FeedSubscription> subscriptions, Long sinceId, Long maxId) {
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, false, null, null, 0, ITEMS_BATCH_SIZE, List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, false, null, null, 0, ITEMS_BATCH_SIZE,
ReadingOrder.desc, false, false, null, sinceId, maxId); ReadingOrder.desc, false, null, sinceId, maxId);
return statuses.stream().map(this::mapStatus).toList(); return statuses.stream().map(this::mapStatus).toList();
} }
@@ -295,7 +295,7 @@ public class FeverREST {
i.setUrl(s.getEntry().getUrl()); i.setUrl(s.getEntry().getUrl());
i.setSaved(s.isStarred()); i.setSaved(s.isStarred());
i.setRead(s.isRead()); i.setRead(s.isRead());
i.setCreatedOnTime(s.getEntryUpdated().getEpochSecond()); i.setCreatedOnTime(s.getEntryPublished().getEpochSecond());
return i; return i;
} }

View File

@@ -68,7 +68,7 @@ public class NextUnreadServlet extends HttpServlet {
if (StringUtils.isBlank(categoryId) || CategoryREST.ALL.equals(categoryId)) { if (StringUtils.isBlank(categoryId) || CategoryREST.ALL.equals(categoryId)) {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user.get()); List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user.get());
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user.get(), subs, true, null, null, 0, 1, order, List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user.get(), subs, true, null, null, 0, 1, order,
true, false, null, null, null); true, null, null, null);
s = Iterables.getFirst(statuses, null); s = Iterables.getFirst(statuses, null);
} else { } else {
FeedCategory category = feedCategoryDAO.findById(user.get(), Long.valueOf(categoryId)); FeedCategory category = feedCategoryDAO.findById(user.get(), Long.valueOf(categoryId));
@@ -76,7 +76,7 @@ public class NextUnreadServlet extends HttpServlet {
List<FeedCategory> children = feedCategoryDAO.findAllChildrenCategories(user.get(), category); List<FeedCategory> children = feedCategoryDAO.findAllChildrenCategories(user.get(), category);
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findByCategories(user.get(), children); List<FeedSubscription> subscriptions = feedSubscriptionDAO.findByCategories(user.get(), children);
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user.get(), subscriptions, true, null, null, 0, List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user.get(), subscriptions, true, null, null, 0,
1, order, true, false, null, null, null); 1, order, true, null, null, null);
s = Iterables.getFirst(statuses, null); s = Iterables.getFirst(statuses, null);
} }
} }

View File

@@ -39,7 +39,7 @@ public class WebSocketSessions {
public void sendMessage(User user, String text) { public void sendMessage(User user, String text) {
Set<Session> userSessions = sessions.get(user.getId()); Set<Session> userSessions = sessions.get(user.getId());
if (userSessions != null && !userSessions.isEmpty()) { if (userSessions != null && !userSessions.isEmpty()) {
log.debug("sending '{}' to {} users via websocket", text, userSessions.size()); log.debug("sending '{}' to user {} via websocket ({} sessions)", text, user.getId(), userSessions.size());
for (Session userSession : userSessions) { for (Session userSession : userSessions) {
if (userSession.isOpen()) { if (userSession.isOpen()) {
userSession.getAsyncRemote().sendText(text); userSession.getAsyncRemote().sendText(text);

View File

@@ -1,23 +1,100 @@
package com.commafeed; package com.commafeed;
import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection; import java.sql.Connection;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import javax.sql.DataSource; import javax.sql.DataSource;
import org.mockserver.socket.PortFactory; import org.mockserver.socket.PortFactory;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.JdbcDatabaseContainer;
import org.testcontainers.containers.MariaDBContainer;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.MetricRegistry;
import com.commafeed.CommaFeedConfiguration.CacheType;
import io.dropwizard.testing.ConfigOverride; import io.dropwizard.testing.ConfigOverride;
import io.dropwizard.testing.ResourceHelpers; import io.dropwizard.testing.ResourceHelpers;
import io.dropwizard.testing.junit5.DropwizardAppExtension; import io.dropwizard.testing.junit5.DropwizardAppExtension;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
public class CommaFeedDropwizardAppExtension extends DropwizardAppExtension<CommaFeedConfiguration> { public class CommaFeedDropwizardAppExtension extends DropwizardAppExtension<CommaFeedConfiguration> {
private static final String TEST_DATABASE = System.getenv().getOrDefault("TEST_DATABASE", "h2");
private static final boolean REDIS_ENABLED = Boolean.parseBoolean(System.getenv().getOrDefault("REDIS", "false"));
private static final ConfigOverride[] CONFIG_OVERRIDES;
private static final List<String> DROP_ALL_STATEMENTS;
static {
List<ConfigOverride> overrides = new ArrayList<>();
overrides.add(ConfigOverride.config("server.applicationConnectors[0].port", String.valueOf(PortFactory.findFreePort())));
Properties imageNames = readProperties("/docker-images.properties");
DatabaseConfiguration config = buildConfiguration(TEST_DATABASE, imageNames.getProperty(TEST_DATABASE));
JdbcDatabaseContainer<?> container = config.container();
if (container != null) {
container.withDatabaseName("commafeed");
container.withEnv("TZ", "UTC");
container.start();
overrides.add(ConfigOverride.config("database.url", container.getJdbcUrl()));
overrides.add(ConfigOverride.config("database.user", container.getUsername()));
overrides.add(ConfigOverride.config("database.password", container.getPassword()));
overrides.add(ConfigOverride.config("database.driverClass", container.getDriverClassName()));
}
if (REDIS_ENABLED) {
GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse(imageNames.getProperty("redis")))
.withExposedPorts(6379);
redis.start();
overrides.add(ConfigOverride.config("app.cache", "redis"));
overrides.add(ConfigOverride.config("redis.host", redis.getHost()));
overrides.add(ConfigOverride.config("redis.port", redis.getMappedPort(6379).toString()));
}
CONFIG_OVERRIDES = overrides.toArray(new ConfigOverride[0]);
DROP_ALL_STATEMENTS = config.dropAllStatements();
}
public CommaFeedDropwizardAppExtension() { public CommaFeedDropwizardAppExtension() {
super(CommaFeedApplication.class, ResourceHelpers.resourceFilePath("config.test.yml"), super(CommaFeedApplication.class, ResourceHelpers.resourceFilePath("config.test.yml"), CONFIG_OVERRIDES);
ConfigOverride.config("server.applicationConnectors[0].port", String.valueOf(PortFactory.findFreePort()))); }
private static DatabaseConfiguration buildConfiguration(String databaseName, String imageName) {
if ("postgresql".equals(databaseName)) {
JdbcDatabaseContainer<?> container = new PostgreSQLContainer<>(imageName).withTmpFs(Map.of("/var/lib/postgresql/data", "rw"));
return new DatabaseConfiguration(container, List.of("DROP SCHEMA public CASCADE", "CREATE SCHEMA public"));
} else if ("mysql".equals(databaseName)) {
JdbcDatabaseContainer<?> container = new MySQLContainer<>(imageName).withTmpFs(Map.of("/var/lib/mysql", "rw"));
return new DatabaseConfiguration(container, List.of("DROP DATABASE IF EXISTS commafeed", " CREATE DATABASE commafeed"));
} else if ("mariadb".equals(databaseName)) {
JdbcDatabaseContainer<?> container = new MariaDBContainer<>(imageName).withTmpFs(Map.of("/var/lib/mysql", "rw"));
return new DatabaseConfiguration(container, List.of("DROP DATABASE IF EXISTS commafeed", " CREATE DATABASE commafeed"));
} else {
// h2
return new DatabaseConfiguration(null, List.of("DROP ALL OBJECTS"));
}
}
private static Properties readProperties(String path) {
Properties properties = new Properties();
try (InputStream is = CommaFeedDropwizardAppExtension.class.getResourceAsStream(path)) {
properties.load(is);
} catch (IOException e) {
throw new RuntimeException("could not read resource " + path, e);
}
return properties;
} }
@Override @Override
@@ -27,10 +104,22 @@ public class CommaFeedDropwizardAppExtension extends DropwizardAppExtension<Comm
// clean database after each test // clean database after each test
DataSource dataSource = getConfiguration().getDataSourceFactory().build(new MetricRegistry(), "cleanup"); DataSource dataSource = getConfiguration().getDataSourceFactory().build(new MetricRegistry(), "cleanup");
try (Connection connection = dataSource.getConnection()) { try (Connection connection = dataSource.getConnection()) {
connection.prepareStatement("DROP ALL OBJECTS").executeUpdate(); for (String statement : DROP_ALL_STATEMENTS) {
connection.prepareStatement(statement).executeUpdate();
}
} catch (SQLException e) { } catch (SQLException e) {
throw new RuntimeException("could not cleanup database", e); throw new RuntimeException("could not cleanup database", e);
} }
// clean redis cache after each test
if (getConfiguration().getApplicationSettings().getCache() == CacheType.REDIS) {
try (JedisPool pool = getConfiguration().getRedisPoolFactory().build(); Jedis jedis = pool.getResource()) {
jedis.flushAll();
}
}
}
private record DatabaseConfiguration(JdbcDatabaseContainer<?> container, List<String> dropAllStatements) {
} }
} }

View File

@@ -1,90 +0,0 @@
package com.commafeed.backend;
import java.util.Comparator;
import org.apache.commons.lang3.ObjectUtils;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class FixedSizeSortedListTest {
private static final Comparator<String> COMP = ObjectUtils::compare;
private FixedSizeSortedList<String> list;
@BeforeEach
public void init() {
list = new FixedSizeSortedList<>(3, COMP);
}
@Test
void testSimpleAdd() {
list.add("0");
list.add("1");
list.add("2");
Assertions.assertEquals("0", list.asList().get(0));
Assertions.assertEquals("1", list.asList().get(1));
Assertions.assertEquals("2", list.asList().get(2));
}
@Test
void testIsFull() {
list.add("0");
list.add("1");
Assertions.assertFalse(list.isFull());
list.add("2");
Assertions.assertTrue(list.isFull());
}
@Test
void testOrder() {
list.add("2");
list.add("1");
list.add("0");
Assertions.assertEquals("0", list.asList().get(0));
Assertions.assertEquals("1", list.asList().get(1));
Assertions.assertEquals("2", list.asList().get(2));
}
@Test
void testEviction() {
list.add("7");
list.add("8");
list.add("9");
list.add("0");
list.add("1");
list.add("2");
Assertions.assertEquals("0", list.asList().get(0));
Assertions.assertEquals("1", list.asList().get(1));
Assertions.assertEquals("2", list.asList().get(2));
}
@Test
void testCapacity() {
list.add("0");
list.add("1");
list.add("2");
list.add("3");
Assertions.assertEquals(3, list.asList().size());
}
@Test
void testLast() {
list.add("0");
list.add("1");
list.add("2");
Assertions.assertEquals("2", list.last());
list.add("3");
Assertions.assertEquals("2", list.last());
}
}

View File

@@ -54,6 +54,7 @@ public abstract class PlaywrightTestBase {
} }
protected void customizeNewContextOptions(NewContextOptions options) { protected void customizeNewContextOptions(NewContextOptions options) {
// override in subclasses to customize the browser context
} }
protected static class SaveArtifactsOnTestFailed implements TestWatcher, BeforeEachCallback { protected static class SaveArtifactsOnTestFailed implements TestWatcher, BeforeEachCallback {

View File

@@ -10,7 +10,6 @@ import org.apache.commons.io.IOUtils;
import org.awaitility.Awaitility; import org.awaitility.Awaitility;
import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.http.HttpStatus;
import org.glassfish.jersey.client.JerseyClientBuilder; import org.glassfish.jersey.client.JerseyClientBuilder;
import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;
import org.glassfish.jersey.media.multipart.MultiPartFeature; import org.glassfish.jersey.media.multipart.MultiPartFeature;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
@@ -40,7 +39,12 @@ public abstract class BaseIT {
private static final HttpRequest FEED_REQUEST = HttpRequest.request().withMethod("GET").withPath("/"); private static final HttpRequest FEED_REQUEST = HttpRequest.request().withMethod("GET").withPath("/");
private final CommaFeedDropwizardAppExtension extension = buildExtension(); private final CommaFeedDropwizardAppExtension extension = new CommaFeedDropwizardAppExtension() {
@Override
protected JerseyClientBuilder clientBuilder() {
return configureClientBuilder(super.clientBuilder().register(MultiPartFeature.class));
}
};
private Client client; private Client client;
@@ -54,13 +58,8 @@ public abstract class BaseIT {
private MockServerClient mockServerClient; private MockServerClient mockServerClient;
protected CommaFeedDropwizardAppExtension buildExtension() { protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) {
return new CommaFeedDropwizardAppExtension() { return base;
@Override
protected JerseyClientBuilder clientBuilder() {
return super.clientBuilder().register(HttpAuthenticationFeature.basic("admin", "admin")).register(MultiPartFeature.class);
}
};
} }
@BeforeEach @BeforeEach

View File

@@ -6,7 +6,6 @@ import org.eclipse.jetty.http.HttpStatus;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import com.commafeed.CommaFeedDropwizardAppExtension;
import com.commafeed.frontend.model.Entries; import com.commafeed.frontend.model.Entries;
import com.commafeed.frontend.model.UserModel; import com.commafeed.frontend.model.UserModel;
import com.commafeed.frontend.model.request.ProfileModificationRequest; import com.commafeed.frontend.model.request.ProfileModificationRequest;
@@ -18,12 +17,6 @@ import jakarta.ws.rs.core.Response;
class SecurityIT extends BaseIT { class SecurityIT extends BaseIT {
@Override
protected CommaFeedDropwizardAppExtension buildExtension() {
// override so we don't add http basic auth
return new CommaFeedDropwizardAppExtension();
}
@Test @Test
void notLoggedIn() { void notLoggedIn() {
try (Response response = getClient().target(getApiBaseUrl() + "user/profile").request().get()) { try (Response response = getClient().target(getApiBaseUrl() + "user/profile").request().get()) {

View File

@@ -10,6 +10,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import org.awaitility.Awaitility; import org.awaitility.Awaitility;
import org.glassfish.jersey.client.JerseyClientBuilder;
import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -28,6 +30,11 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
class WebSocketIT extends BaseIT { class WebSocketIT extends BaseIT {
@Override
protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) {
return base.register(HttpAuthenticationFeature.basic("admin", "admin"));
}
@Test @Test
void sessionClosedIfNotLoggedIn() throws DeploymentException, IOException { void sessionClosedIfNotLoggedIn() throws DeploymentException, IOException {
AtomicBoolean connected = new AtomicBoolean(); AtomicBoolean connected = new AtomicBoolean();

View File

@@ -3,6 +3,8 @@ package com.commafeed.integration.rest;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import org.glassfish.jersey.client.JerseyClientBuilder;
import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -17,6 +19,11 @@ import jakarta.ws.rs.client.Entity;
class AdminIT extends BaseIT { class AdminIT extends BaseIT {
@Override
protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) {
return base.register(HttpAuthenticationFeature.basic("admin", "admin"));
}
@Test @Test
void getApplicationSettings() { void getApplicationSettings() {
ApplicationSettings settings = getClient().target(getApiBaseUrl() + "admin/settings").request().get(ApplicationSettings.class); ApplicationSettings settings = getClient().target(getApiBaseUrl() + "admin/settings").request().get(ApplicationSettings.class);

View File

@@ -13,6 +13,8 @@ import org.apache.commons.lang3.StringUtils;
import org.awaitility.Awaitility; import org.awaitility.Awaitility;
import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.http.HttpStatus;
import org.glassfish.jersey.client.ClientProperties; import org.glassfish.jersey.client.ClientProperties;
import org.glassfish.jersey.client.JerseyClientBuilder;
import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;
import org.glassfish.jersey.media.multipart.MultiPart; import org.glassfish.jersey.media.multipart.MultiPart;
import org.glassfish.jersey.media.multipart.file.StreamDataBodyPart; import org.glassfish.jersey.media.multipart.file.StreamDataBodyPart;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
@@ -34,6 +36,11 @@ import jakarta.ws.rs.core.Response;
class FeedIT extends BaseIT { class FeedIT extends BaseIT {
@Override
protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) {
return base.register(HttpAuthenticationFeature.basic("admin", "admin"));
}
@Nested @Nested
class Fetch { class Fetch {
@Test @Test
@@ -105,10 +112,11 @@ class FeedIT extends BaseIT {
@Test @Test
void markInsertedBeforeBeforeSubscription() { void markInsertedBeforeBeforeSubscription() {
Instant insertedBefore = Instant.now(); // mariadb/mysql timestamp precision is 1 second
Instant threshold = Instant.now().minus(Duration.ofSeconds(1));
long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); long subscriptionId = subscribeAndWaitForEntries(getFeedUrl());
markFeedEntries(subscriptionId, null, insertedBefore); markFeedEntries(subscriptionId, null, threshold);
Assertions.assertTrue(getFeedEntries(subscriptionId).getEntries().stream().noneMatch(Entry::isRead)); Assertions.assertTrue(getFeedEntries(subscriptionId).getEntries().stream().noneMatch(Entry::isRead));
} }
@@ -116,9 +124,10 @@ class FeedIT extends BaseIT {
void markInsertedBeforeAfterSubscription() { void markInsertedBeforeAfterSubscription() {
long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); long subscriptionId = subscribeAndWaitForEntries(getFeedUrl());
Instant insertedBefore = Instant.now(); // mariadb/mysql timestamp precision is 1 second
Instant threshold = Instant.now().plus(Duration.ofSeconds(1));
markFeedEntries(subscriptionId, null, insertedBefore); markFeedEntries(subscriptionId, null, threshold);
Assertions.assertTrue(getFeedEntries(subscriptionId).getEntries().stream().allMatch(Entry::isRead)); Assertions.assertTrue(getFeedEntries(subscriptionId).getEntries().stream().allMatch(Entry::isRead));
} }
@@ -137,26 +146,29 @@ class FeedIT extends BaseIT {
void refresh() { void refresh() {
Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl());
Instant now = Instant.now(); // mariadb/mysql timestamp precision is 1 second
Instant threshold = Instant.now().minus(Duration.ofSeconds(1));
IDRequest request = new IDRequest(); IDRequest request = new IDRequest();
request.setId(subscriptionId); request.setId(subscriptionId);
getClient().target(getApiBaseUrl() + "feed/refresh").request().post(Entity.json(request), Void.TYPE); getClient().target(getApiBaseUrl() + "feed/refresh").request().post(Entity.json(request), Void.TYPE);
Awaitility.await() Awaitility.await()
.atMost(Duration.ofSeconds(15)) .atMost(Duration.ofSeconds(15))
.until(() -> getSubscription(subscriptionId), f -> f.getLastRefresh().isAfter(now)); .until(() -> getSubscription(subscriptionId), f -> f.getLastRefresh().isAfter(threshold));
} }
@Test @Test
void refreshAll() { void refreshAll() {
Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl()); Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl());
Instant now = Instant.now(); // mariadb/mysql timestamp precision is 1 second
Instant threshold = Instant.now().minus(Duration.ofSeconds(1));
getClient().target(getApiBaseUrl() + "feed/refreshAll").request().get(Void.TYPE); getClient().target(getApiBaseUrl() + "feed/refreshAll").request().get(Void.TYPE);
Awaitility.await() Awaitility.await()
.atMost(Duration.ofSeconds(15)) .atMost(Duration.ofSeconds(15))
.until(() -> getSubscription(subscriptionId), f -> f.getLastRefresh().isAfter(now)); .until(() -> getSubscription(subscriptionId), f -> f.getLastRefresh().isAfter(threshold));
} }
} }

View File

@@ -1,5 +1,7 @@
package com.commafeed.integration.rest; package com.commafeed.integration.rest;
import org.glassfish.jersey.client.JerseyClientBuilder;
import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -18,6 +20,11 @@ class FeverIT extends BaseIT {
private Long userId; private Long userId;
private String apiKey; private String apiKey;
@Override
protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) {
return base.register(HttpAuthenticationFeature.basic("admin", "admin"));
}
@BeforeEach @BeforeEach
void init() { void init() {
// create api key // create api key

View File

@@ -6,7 +6,7 @@ import org.junit.jupiter.api.Test;
import com.commafeed.frontend.model.ServerInfo; import com.commafeed.frontend.model.ServerInfo;
import com.commafeed.integration.BaseIT; import com.commafeed.integration.BaseIT;
public class ServerIT extends BaseIT { class ServerIT extends BaseIT {
@Test @Test
void getServerInfos() { void getServerInfos() {

View File

@@ -1,5 +1,7 @@
package com.commafeed.integration.servlet; package com.commafeed.integration.servlet;
import org.glassfish.jersey.client.JerseyClientBuilder;
import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -12,6 +14,11 @@ import jakarta.ws.rs.core.Response;
class CustomCodeIT extends BaseIT { class CustomCodeIT extends BaseIT {
@Override
protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) {
return base.register(HttpAuthenticationFeature.basic("admin", "admin"));
}
@Test @Test
void test() { void test() {
// get settings // get settings

View File

@@ -5,7 +5,6 @@ import org.glassfish.jersey.client.ClientProperties;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import com.commafeed.CommaFeedDropwizardAppExtension;
import com.commafeed.frontend.model.UserModel; import com.commafeed.frontend.model.UserModel;
import com.commafeed.integration.BaseIT; import com.commafeed.integration.BaseIT;
@@ -16,12 +15,6 @@ import jakarta.ws.rs.core.Response;
class LogoutIT extends BaseIT { class LogoutIT extends BaseIT {
@Override
protected CommaFeedDropwizardAppExtension buildExtension() {
// override so we don't add http basic auth
return new CommaFeedDropwizardAppExtension();
}
@Test @Test
void test() { void test() {
String cookie = login(); String cookie = login();

View File

@@ -2,6 +2,8 @@ package com.commafeed.integration.servlet;
import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.http.HttpStatus;
import org.glassfish.jersey.client.ClientProperties; import org.glassfish.jersey.client.ClientProperties;
import org.glassfish.jersey.client.JerseyClientBuilder;
import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -12,6 +14,11 @@ import jakarta.ws.rs.core.Response;
class NextUnreadIT extends BaseIT { class NextUnreadIT extends BaseIT {
@Override
protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) {
return base.register(HttpAuthenticationFeature.basic("admin", "admin"));
}
@Test @Test
void test() { void test() {
subscribeAndWaitForEntries(getFeedUrl()); subscribeAndWaitForEntries(getFeedUrl());

View File

@@ -104,10 +104,6 @@ app:
# for PostgreSQL # for PostgreSQL
# driverClass is org.postgresql.Driver # driverClass is org.postgresql.Driver
# url is jdbc:postgresql://localhost:5432/commafeed # url is jdbc:postgresql://localhost:5432/commafeed
#
# for Microsoft SQL Server
# driverClass is net.sourceforge.jtds.jdbc.Driver
# url is jdbc:jtds:sqlserver://localhost:1433/commafeed;instance=<instanceName, remove if not needed>
database: database:
driverClass: org.h2.Driver driverClass: org.h2.Driver
@@ -117,6 +113,9 @@ database:
properties: properties:
charSet: UTF-8 charSet: UTF-8
validationQuery: "/* CommaFeed Health Check */ SELECT 1" validationQuery: "/* CommaFeed Health Check */ SELECT 1"
minSize: 1
maxSize: 5
initialSize: 1
server: server:
applicationConnectors: applicationConnectors:

View File

@@ -0,0 +1,4 @@
postgresql=postgres:${postgresql.image.version}
mysql=mysql:${mysql.image.version}
mariadb=mariadb:${mariadb.image.version}
redis=redis:${redis.image.version}

13
pom.xml
View File

@@ -5,7 +5,7 @@
<groupId>com.commafeed</groupId> <groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId> <artifactId>commafeed</artifactId>
<version>4.4.1</version> <version>4.6.0</version>
<name>CommaFeed</name> <name>CommaFeed</name>
<packaging>pom</packaging> <packaging>pom</packaging>
@@ -22,6 +22,17 @@
<version>3.13.0</version> <version>3.13.0</version>
<configuration> <configuration>
<parameters>true</parameters> <parameters>true</parameters>
<!-- treat warnings as errors -->
<!-- https://stackoverflow.com/a/33823355/ -->
<showWarnings>true</showWarnings>
<compilerArgs>
<!-- disable the "processing" linter because we have annotations that are processed at runtime -->
<!-- https://stackoverflow.com/a/76126981/ -->
<!-- disable the "classfile" linter because it generates "file missing" warnings about annotations with the "provided" scope -->
<arg>-Xlint:all,-processing,-classfile</arg>
<arg>-Werror</arg>
</compilerArgs>
</configuration> </configuration>
</plugin> </plugin>
</plugins> </plugins>

View File

@@ -2,7 +2,12 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [ "extends": [
"config:recommended", "config:recommended",
"customManagers:biomeVersions" "customManagers:mavenPropertyVersions",
"customManagers:biomeVersions",
":automergePatch",
":automergeBranch",
":automergeRequireAllStatusChecks",
":maintainLockFilesWeekly"
], ],
"packageRules": [ "packageRules": [
{ {