mirror of
https://github.com/Athou/commafeed.git
synced 2026-03-21 21:37:29 +00:00
Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
346fb6b1ea | ||
|
|
1b658c76a3 | ||
|
|
1593ed62ba | ||
|
|
085eddd4b0 | ||
|
|
0db77ad2c0 | ||
|
|
6f8bcb6c6a | ||
|
|
4196dee896 | ||
|
|
6d49e0f0df | ||
|
|
d99f572989 | ||
|
|
fa197c33f1 | ||
|
|
1ce39a419e | ||
|
|
f0e3ac8fcb | ||
|
|
30947cea05 | ||
|
|
9134f36d3b | ||
|
|
dc526316a0 | ||
|
|
6593174668 | ||
|
|
0891c41abc | ||
|
|
6ecb6254aa | ||
|
|
84bd9eeeff | ||
|
|
2549c4d47b | ||
|
|
8750aa3dd6 | ||
|
|
262094a736 | ||
|
|
035201f917 | ||
|
|
ae9cbc5214 | ||
|
|
78d5bf129a | ||
|
|
1f02ddd163 | ||
|
|
eff1e8cc7b | ||
|
|
dc8475b59a | ||
|
|
921968662d | ||
|
|
4d83173dbd | ||
|
|
f13368cb96 | ||
|
|
ec7e97e1de | ||
|
|
d4c9bd1dd7 | ||
|
|
6bff657d4d | ||
|
|
613d286be1 | ||
|
|
fd48108f8b | ||
|
|
c3cbd18df9 | ||
|
|
6685057dae | ||
|
|
0dec0e3788 | ||
|
|
1a73dd4004 | ||
|
|
eae80a6450 | ||
|
|
21a32ce0eb | ||
|
|
325533c5d9 | ||
|
|
7d819022f6 | ||
|
|
dba944874b | ||
|
|
ce9c12ec92 | ||
|
|
22dfc5774f | ||
|
|
d59091ab2b | ||
|
|
f69146a6bf | ||
|
|
43cdf3db3b | ||
|
|
280a354228 | ||
|
|
573b0431f9 | ||
|
|
9878b60e97 | ||
|
|
964033c2a7 | ||
|
|
d2e45aca91 | ||
|
|
daa99a2efc | ||
|
|
e986e9999a | ||
|
|
98d302cb94 | ||
|
|
bf11c4a7e4 | ||
|
|
e1cab952f8 | ||
|
|
bc28d4de27 | ||
|
|
bb901564e3 | ||
|
|
93acc9ded1 | ||
|
|
9b1c6a371e | ||
|
|
82bf8cd807 | ||
|
|
c2f2780c3f | ||
|
|
08f71d1f6f | ||
|
|
f498088beb | ||
|
|
347b41cf35 | ||
|
|
61ae90ad28 | ||
|
|
9a42fbafb2 | ||
|
|
938f9e9434 | ||
|
|
9004e453c2 | ||
|
|
7d33542691 | ||
|
|
c99348862c | ||
|
|
ac86db3966 | ||
|
|
e368810731 | ||
|
|
edae2f5a61 | ||
|
|
ab17c6f44e | ||
|
|
59dbae4f66 | ||
|
|
d7956292df | ||
|
|
1075497559 | ||
|
|
2d99fa03d3 | ||
|
|
72b64b6f0d | ||
|
|
a2096d3622 | ||
|
|
c81f9fb7b1 | ||
|
|
cc7e9e21fb | ||
|
|
803d537e51 | ||
|
|
9a83e5b6ef | ||
|
|
4323da9007 | ||
|
|
30b9b24be4 | ||
|
|
b191b00003 | ||
|
|
7e5cdcba34 | ||
|
|
45b30ad333 | ||
|
|
7ca087b0a6 | ||
|
|
188e4594fd | ||
|
|
2da80ce7d8 | ||
|
|
d5820f9aa5 | ||
|
|
b1a0aae0a5 | ||
|
|
cdd4d4b063 | ||
|
|
21f675e80b | ||
|
|
380724d73e | ||
|
|
2d26c5dee3 | ||
|
|
29bcc5ccf5 | ||
|
|
91497ab45a | ||
|
|
be77968570 | ||
|
|
a42dacc48d |
@@ -3,4 +3,6 @@
|
||||
|
||||
# allow only what we need
|
||||
!commafeed-server/target/commafeed.jar
|
||||
!commafeed-server/config.docker-warmup.yml
|
||||
!commafeed-server/config.yml.example
|
||||
|
||||
|
||||
32
.github/workflows/build.yml
vendored
32
.github/workflows/build.yml
vendored
@@ -29,10 +29,34 @@ jobs:
|
||||
distribution: "temurin"
|
||||
cache: "maven"
|
||||
|
||||
# Build
|
||||
# Build & Test
|
||||
- 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
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ matrix.java == '17' }}
|
||||
@@ -57,7 +81,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- 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' }}
|
||||
with:
|
||||
context: .
|
||||
@@ -68,7 +92,7 @@ jobs:
|
||||
athou/commafeed:${{ github.ref_name }}
|
||||
|
||||
- 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' }}
|
||||
with:
|
||||
context: .
|
||||
|
||||
2
.mvn/wrapper/maven-wrapper.properties
vendored
2
.mvn/wrapper/maven-wrapper.properties
vendored
@@ -15,4 +15,4 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
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
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,5 +1,24 @@
|
||||
# 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]
|
||||
|
||||
- fix vertical scrolling issues with Safari (#1168)
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -1,12 +1,19 @@
|
||||
FROM eclipse-temurin:21.0.3_9-jre
|
||||
FROM ibm-semeru-runtimes:open-21-jre
|
||||
|
||||
EXPOSE 8082
|
||||
|
||||
RUN mkdir -p /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/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
|
||||
CMD ["java", "-jar", "commafeed.jar", "server", "config.yml"]
|
||||
# build openj9 shared classes cache to improve startup time
|
||||
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"]
|
||||
|
||||
17
README.md
17
README.md
@@ -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
|
||||
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
|
||||
|
||||
@@ -67,16 +67,25 @@ For example, to limit the JVM to 256MB of memory, use `-Xmx256m`.
|
||||
|
||||
#### 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)
|
||||
and [here](https://docs.oracle.com/en/java/javase/17/gctuning/factors-affecting-garbage-collection-performance.html) for
|
||||
more
|
||||
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
|
||||
|
||||
Files for internationalization are
|
||||
|
||||
@@ -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": {
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 4,
|
||||
|
||||
3311
commafeed-client/package-lock.json
generated
3311
commafeed-client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,22 +17,22 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
"@lingui/core": "^4.11.1",
|
||||
"@lingui/macro": "^4.11.1",
|
||||
"@lingui/react": "^4.11.1",
|
||||
"@mantine/core": "^7.10.2",
|
||||
"@mantine/form": "^7.10.2",
|
||||
"@mantine/hooks": "^7.10.2",
|
||||
"@mantine/modals": "^7.10.2",
|
||||
"@mantine/notifications": "^7.10.2",
|
||||
"@mantine/spotlight": "^7.10.2",
|
||||
"@lingui/core": "^4.11.2",
|
||||
"@lingui/macro": "^4.11.2",
|
||||
"@lingui/react": "^4.11.2",
|
||||
"@mantine/core": "^7.11.2",
|
||||
"@mantine/form": "^7.11.2",
|
||||
"@mantine/hooks": "^7.11.2",
|
||||
"@mantine/modals": "^7.11.2",
|
||||
"@mantine/notifications": "^7.11.2",
|
||||
"@mantine/spotlight": "^7.11.2",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@reduxjs/toolkit": "^2.2.6",
|
||||
"axios": "^1.7.2",
|
||||
"dayjs": "^1.11.11",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"interweave": "^13.1.0",
|
||||
"monaco-editor": "^0.49.0",
|
||||
"monaco-editor": "^0.50.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"react": "^18.3.1",
|
||||
"react-async-hook": "^4.0.0",
|
||||
@@ -45,20 +45,20 @@
|
||||
"react-icons": "^5.2.1",
|
||||
"react-infinite-scroller": "^1.2.6",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"react-router-dom": "^6.24.1",
|
||||
"react-swipeable": "^7.0.1",
|
||||
"redoc": "^2.1.5",
|
||||
"throttle-debounce": "^5.0.0",
|
||||
"throttle-debounce": "^5.0.2",
|
||||
"tinycon": "^0.6.8",
|
||||
"tss-react": "^4.9.10",
|
||||
"use-local-storage": "^3.0.0",
|
||||
"vite-plugin-biome": "^1.0.10",
|
||||
"vite-plugin-biome": "^1.0.12",
|
||||
"websocket-heartbeat-js": "^1.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.8.1",
|
||||
"@lingui/cli": "^4.11.1",
|
||||
"@lingui/vite-plugin": "^4.11.1",
|
||||
"@biomejs/biome": "^1.8.3",
|
||||
"@lingui/cli": "^4.11.2",
|
||||
"@lingui/vite-plugin": "^4.11.2",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
@@ -70,10 +70,10 @@
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.3.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.3",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^1.6.0",
|
||||
"vitest": "^2.0.2",
|
||||
"vitest-mock-extended": "^1.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
<parent>
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed</artifactId>
|
||||
<version>4.4.1</version>
|
||||
<version>4.6.0</version>
|
||||
</parent>
|
||||
<artifactId>commafeed-client</artifactId>
|
||||
<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>
|
||||
<plugins>
|
||||
<plugin>
|
||||
@@ -25,8 +33,8 @@
|
||||
</goals>
|
||||
<phase>compile</phase>
|
||||
<configuration>
|
||||
<nodeVersion>v20.10.0</nodeVersion>
|
||||
<npmVersion>10.2.5</npmVersion>
|
||||
<nodeVersion>${node.version}</nodeVersion>
|
||||
<npmVersion>${npm.version}</npmVersion>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type RootState, reducers } from "app/store"
|
||||
import type { Entries, Entry } from "app/types"
|
||||
import type { AxiosResponse } from "axios"
|
||||
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 mockModule = await import("vitest-mock-extended")
|
||||
@@ -19,7 +19,7 @@ describe("entries", () => {
|
||||
})
|
||||
|
||||
it("loads entries", async () => {
|
||||
mockClient.feed.getEntries.mockResolvedValue({
|
||||
mockClient.feed.getEntries.calledWith(any()).mockResolvedValue({
|
||||
data: {
|
||||
entries: [{ id: "3" } as Entry],
|
||||
hasMore: false,
|
||||
@@ -53,7 +53,7 @@ describe("entries", () => {
|
||||
})
|
||||
|
||||
it("loads more entries", async () => {
|
||||
mockClient.category.getEntries.mockResolvedValue({
|
||||
mockClient.category.getEntries.calledWith(any()).mockResolvedValue({
|
||||
data: {
|
||||
entries: [{ id: "4" } as Entry],
|
||||
hasMore: false,
|
||||
|
||||
@@ -40,7 +40,7 @@ export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_,
|
||||
const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({
|
||||
id: source.type === "tag" ? Constants.categories.all.id : source.id,
|
||||
order: state.user.settings?.readingOrder,
|
||||
readType: state.user.settings?.readingMode,
|
||||
readType: state.entries.search ? "all" : state.user.settings?.readingMode,
|
||||
offset,
|
||||
limit: 50,
|
||||
tag: source.type === "tag" ? source.id : undefined,
|
||||
|
||||
@@ -117,7 +117,6 @@ export interface GetEntriesRequest {
|
||||
newerThan?: number
|
||||
order?: ReadingOrder
|
||||
keywords?: string
|
||||
onlyIds?: boolean
|
||||
excludedSubscriptionIds?: string
|
||||
tag?: string
|
||||
}
|
||||
|
||||
@@ -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
|
||||
useEffect(() => {
|
||||
dispatch(
|
||||
const promise = dispatch(
|
||||
loadEntries({
|
||||
source: {
|
||||
type: props.sourceType,
|
||||
@@ -73,6 +73,7 @@ export function FeedEntriesPage(props: FeedEntriesPageProps) {
|
||||
clearSearch: true,
|
||||
})
|
||||
)
|
||||
return () => promise.abort()
|
||||
}, [dispatch, props.sourceType, id, location.state?.timestamp])
|
||||
|
||||
const noSubscriptions = rootCategory && flattenCategoryTree(rootCategory).every(c => c.feeds.length === 0)
|
||||
|
||||
@@ -15,7 +15,6 @@ export default defineConfig(env => ({
|
||||
},
|
||||
}),
|
||||
lingui(),
|
||||
// https://github.com/vitest-dev/vitest/issues/4055#issuecomment-1732994672
|
||||
tsconfigPaths(),
|
||||
visualizer(),
|
||||
biomePlugin({
|
||||
|
||||
@@ -104,10 +104,6 @@ app:
|
||||
# for PostgreSQL
|
||||
# driverClass is org.postgresql.Driver
|
||||
# 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:
|
||||
driverClass: org.h2.Driver
|
||||
|
||||
151
commafeed-server/config.docker-warmup.yml
Normal file
151
commafeed-server/config.docker-warmup.yml
Normal 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
|
||||
|
||||
@@ -104,10 +104,6 @@ app:
|
||||
# for PostgreSQL
|
||||
# driverClass is org.postgresql.Driver
|
||||
# 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:
|
||||
driverClass: org.h2.Driver
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
- MYSQL_ROOT_PASSWORD=root
|
||||
- MYSQL_DATABASE=commafeed
|
||||
ports:
|
||||
- 3306:3306
|
||||
- "3306:3306"
|
||||
|
||||
postgresql:
|
||||
image: postgres
|
||||
@@ -16,4 +16,4 @@ services:
|
||||
- POSTGRES_PASSWORD=root
|
||||
- POSTGRES_DB=commafeed
|
||||
ports:
|
||||
- 5432:5432
|
||||
- "5432:5432"
|
||||
|
||||
@@ -6,15 +6,25 @@
|
||||
<parent>
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed</artifactId>
|
||||
<version>4.4.1</version>
|
||||
<version>4.6.0</version>
|
||||
</parent>
|
||||
<artifactId>commafeed-server</artifactId>
|
||||
<name>CommaFeed Server</name>
|
||||
|
||||
<properties>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<dependencyManagement>
|
||||
@@ -31,17 +41,30 @@
|
||||
|
||||
<build>
|
||||
<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>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<version>3.3.1</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<version>3.3.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
@@ -54,7 +77,7 @@
|
||||
<plugin>
|
||||
<groupId>io.github.git-commit-id</groupId>
|
||||
<artifactId>git-commit-id-maven-plugin</artifactId>
|
||||
<version>9.0.0</version>
|
||||
<version>9.0.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
@@ -64,7 +87,8 @@
|
||||
</executions>
|
||||
<configuration>
|
||||
<generateGitPropertiesFile>true</generateGitPropertiesFile>
|
||||
<generateGitPropertiesFilename>${project.build.outputDirectory}/git.properties</generateGitPropertiesFilename>
|
||||
<generateGitPropertiesFilename>${project.build.outputDirectory}/git.properties
|
||||
</generateGitPropertiesFilename>
|
||||
<failOnNoGitDirectory>false</failOnNoGitDirectory>
|
||||
<failOnUnableToExtractRepoInfo>false</failOnUnableToExtractRepoInfo>
|
||||
</configuration>
|
||||
@@ -86,6 +110,7 @@
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>module-info.class</exclude>
|
||||
<exclude>META-INF/*.SF</exclude>
|
||||
<exclude>META-INF/*.DSA</exclude>
|
||||
<exclude>META-INF/*.RSA</exclude>
|
||||
@@ -145,7 +170,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.4.1</version>
|
||||
<version>3.4.2</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
@@ -211,13 +236,13 @@
|
||||
<dependency>
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed-client</artifactId>
|
||||
<version>4.4.1</version>
|
||||
<version>4.6.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.32</version>
|
||||
<version>1.18.34</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -358,7 +383,7 @@
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.17.2</version>
|
||||
<version>1.18.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ibm.icu</groupId>
|
||||
@@ -403,13 +428,13 @@
|
||||
<dependency>
|
||||
<groupId>com.manticore-projects.tools</groupId>
|
||||
<artifactId>h2migrationtool</artifactId>
|
||||
<version>1.4</version>
|
||||
<version>1.7</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<version>8.4.0</version>
|
||||
<version>9.0.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mariadb.jdbc</groupId>
|
||||
@@ -421,11 +446,6 @@
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.sourceforge.jtds</groupId>
|
||||
<artifactId>jtds</artifactId>
|
||||
<version>1.3.1</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
@@ -467,9 +487,33 @@
|
||||
<dependency>
|
||||
<groupId>com.microsoft.playwright</groupId>
|
||||
<artifactId>playwright</artifactId>
|
||||
<version>1.44.0</version>
|
||||
<version>1.45.0</version>
|
||||
<scope>test</scope>
|
||||
</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>
|
||||
</project>
|
||||
|
||||
@@ -98,9 +98,9 @@ public class CommaFeedApplication extends Application<CommaFeedConfiguration> {
|
||||
configureObjectMapper(bootstrap.getObjectMapper());
|
||||
|
||||
// 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
|
||||
public void run(CommaFeedConfiguration config, Environment environment) throws Exception {
|
||||
public void run(CommaFeedConfiguration config, Environment environment) {
|
||||
DataSourceFactory dataSourceFactory = config.getDataSourceFactory();
|
||||
String url = dataSourceFactory.getUrl();
|
||||
if (isFileBasedH2(url)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import jakarta.inject.Singleton;
|
||||
@Singleton
|
||||
public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
|
||||
|
||||
private final QFeedCategory category = QFeedCategory.feedCategory;
|
||||
private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory;
|
||||
|
||||
@Inject
|
||||
public FeedCategoryDAO(SessionFactory sessionFactory) {
|
||||
@@ -25,31 +25,31 @@ public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
Predicate parentPredicate;
|
||||
if (parent == null) {
|
||||
parentPredicate = category.parent.isNull();
|
||||
parentPredicate = CATEGORY.parent.isNull();
|
||||
} 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) {
|
||||
Predicate parentPredicate;
|
||||
if (parent == null) {
|
||||
parentPredicate = category.parent.isNull();
|
||||
parentPredicate = CATEGORY.parent.isNull();
|
||||
} 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) {
|
||||
|
||||
@@ -18,8 +18,8 @@ import jakarta.inject.Singleton;
|
||||
@Singleton
|
||||
public class FeedDAO extends GenericDAO<Feed> {
|
||||
|
||||
private final QFeed feed = QFeed.feed;
|
||||
private final QFeedSubscription subscription = QFeedSubscription.feedSubscription;
|
||||
private static final QFeed FEED = QFeed.feed;
|
||||
private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription;
|
||||
|
||||
@Inject
|
||||
public FeedDAO(SessionFactory sessionFactory) {
|
||||
@@ -27,25 +27,25 @@ public class FeedDAO extends GenericDAO<Feed> {
|
||||
}
|
||||
|
||||
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) {
|
||||
query.where(JPAExpressions.selectOne()
|
||||
.from(subscription)
|
||||
.join(subscription.user)
|
||||
.where(subscription.feed.id.eq(feed.id), subscription.user.lastLogin.gt(lastLoginThreshold))
|
||||
.from(SUBSCRIPTION)
|
||||
.join(SUBSCRIPTION.user)
|
||||
.where(SUBSCRIPTION.feed.id.eq(FEED.id), SUBSCRIPTION.user.lastLogin.gt(lastLoginThreshold))
|
||||
.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) {
|
||||
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) {
|
||||
return query().selectFrom(feed)
|
||||
.where(feed.normalizedUrlHash.eq(normalizedUrlHash))
|
||||
return query().selectFrom(FEED)
|
||||
.where(FEED.normalizedUrlHash.eq(normalizedUrlHash))
|
||||
.fetch()
|
||||
.stream()
|
||||
.filter(f -> StringUtils.equals(normalizedUrl, f.getNormalizedUrl()))
|
||||
@@ -55,6 +55,6 @@ public class FeedDAO extends GenericDAO<Feed> {
|
||||
|
||||
public List<Feed> findWithoutSubscriptions(int max) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import jakarta.inject.Singleton;
|
||||
@Singleton
|
||||
public class FeedEntryContentDAO extends GenericDAO<FeedEntryContent> {
|
||||
|
||||
private final QFeedEntryContent content = QFeedEntryContent.feedEntryContent;
|
||||
private final QFeedEntry entry = QFeedEntry.feedEntry;
|
||||
private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent;
|
||||
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
|
||||
|
||||
@Inject
|
||||
public FeedEntryContentDAO(SessionFactory sessionFactory) {
|
||||
@@ -25,13 +25,13 @@ public class FeedEntryContentDAO extends GenericDAO<FeedEntryContent> {
|
||||
}
|
||||
|
||||
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) {
|
||||
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();
|
||||
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();
|
||||
|
||||
return deleteQuery(content).where(content.id.in(ids)).execute();
|
||||
return deleteQuery(CONTENT).where(CONTENT.id.in(ids)).execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import lombok.Getter;
|
||||
@Singleton
|
||||
public class FeedEntryDAO extends GenericDAO<FeedEntry> {
|
||||
|
||||
private final QFeedEntry entry = QFeedEntry.feedEntry;
|
||||
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
|
||||
|
||||
@Inject
|
||||
public FeedEntryDAO(SessionFactory sessionFactory) {
|
||||
@@ -27,22 +27,22 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
|
||||
}
|
||||
|
||||
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) {
|
||||
NumberExpression<Long> count = entry.id.count();
|
||||
List<Tuple> tuples = query().select(entry.feed.id, count)
|
||||
.from(entry)
|
||||
.groupBy(entry.feed)
|
||||
NumberExpression<Long> count = ENTRY.id.count();
|
||||
List<Tuple> tuples = query().select(ENTRY.feed.id, count)
|
||||
.from(ENTRY)
|
||||
.groupBy(ENTRY.feed)
|
||||
.having(count.gt(maxCapacity))
|
||||
.limit(max)
|
||||
.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) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,11 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
|
||||
* Delete entries older than a certain date
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -58,7 +62,7 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
|
||||
* Delete the oldest entries of a feed
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,23 +2,20 @@ package com.commafeed.backend.dao;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.commons.collections4.CollectionUtils;
|
||||
import org.apache.commons.lang3.builder.CompareToBuilder;
|
||||
import org.hibernate.SessionFactory;
|
||||
|
||||
import com.commafeed.CommaFeedConfiguration;
|
||||
import com.commafeed.backend.FixedSizeSortedList;
|
||||
import com.commafeed.backend.feed.FeedEntryKeyword;
|
||||
import com.commafeed.backend.feed.FeedEntryKeyword.Mode;
|
||||
import com.commafeed.backend.model.FeedEntry;
|
||||
import com.commafeed.backend.model.FeedEntryContent;
|
||||
import com.commafeed.backend.model.FeedEntryStatus;
|
||||
import com.commafeed.backend.model.FeedEntryTag;
|
||||
import com.commafeed.backend.model.FeedSubscription;
|
||||
import com.commafeed.backend.model.Models;
|
||||
import com.commafeed.backend.model.QFeedEntry;
|
||||
import com.commafeed.backend.model.QFeedEntryContent;
|
||||
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.frontend.model.UnreadCount;
|
||||
import com.google.common.collect.Iterables;
|
||||
import com.google.common.collect.Ordering;
|
||||
import com.querydsl.core.BooleanBuilder;
|
||||
import com.querydsl.core.Tuple;
|
||||
import com.querydsl.jpa.impl.JPAQuery;
|
||||
@@ -38,43 +34,34 @@ import jakarta.inject.Singleton;
|
||||
@Singleton
|
||||
public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
|
||||
|
||||
private static final Comparator<FeedEntryStatus> STATUS_COMPARATOR_DESC = (o1, o2) -> {
|
||||
CompareToBuilder builder = new CompareToBuilder();
|
||||
builder.append(o2.getEntryUpdated(), o1.getEntryUpdated());
|
||||
builder.append(o2.getId(), o1.getId());
|
||||
return builder.toComparison();
|
||||
};
|
||||
private static final QFeedEntryStatus STATUS = QFeedEntryStatus.feedEntryStatus;
|
||||
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
|
||||
private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent;
|
||||
private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag;
|
||||
|
||||
private static final Comparator<FeedEntryStatus> STATUS_COMPARATOR_ASC = Ordering.from(STATUS_COMPARATOR_DESC).reverse();
|
||||
|
||||
private final FeedEntryDAO feedEntryDAO;
|
||||
private final FeedEntryTagDAO feedEntryTagDAO;
|
||||
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
|
||||
public FeedEntryStatusDAO(SessionFactory sessionFactory, FeedEntryDAO feedEntryDAO, FeedEntryTagDAO feedEntryTagDAO,
|
||||
CommaFeedConfiguration config) {
|
||||
public FeedEntryStatusDAO(SessionFactory sessionFactory, FeedEntryTagDAO feedEntryTagDAO, CommaFeedConfiguration config) {
|
||||
super(sessionFactory);
|
||||
this.feedEntryDAO = feedEntryDAO;
|
||||
this.feedEntryTagDAO = feedEntryTagDAO;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
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);
|
||||
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) {
|
||||
if (status == null) {
|
||||
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.setRead(read);
|
||||
status.setMarkable(!read);
|
||||
@@ -84,101 +71,103 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
|
||||
return status;
|
||||
}
|
||||
|
||||
private FeedEntryStatus fetchTags(User user, FeedEntryStatus status) {
|
||||
List<FeedEntryTag> tags = feedEntryTagDAO.findByEntry(user, status.getEntry());
|
||||
status.setTags(tags);
|
||||
return status;
|
||||
private void fetchTags(User user, List<FeedEntryStatus> statuses) {
|
||||
Map<Long, List<FeedEntryTag>> tagsByEntryIds = feedEntryTagDAO.findByEntries(user,
|
||||
statuses.stream().map(FeedEntryStatus::getEntry).toList());
|
||||
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,
|
||||
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) {
|
||||
query.where(status.entryInserted.gt(newerThan));
|
||||
query.where(STATUS.entryInserted.gt(newerThan));
|
||||
}
|
||||
|
||||
if (order == ReadingOrder.asc) {
|
||||
query.orderBy(status.entryUpdated.asc(), status.id.asc());
|
||||
query.orderBy(STATUS.entryPublished.asc(), STATUS.id.asc());
|
||||
} 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());
|
||||
|
||||
List<FeedEntryStatus> statuses = query.fetch();
|
||||
for (FeedEntryStatus status : statuses) {
|
||||
status = handleStatus(user, status, status.getSubscription(), status.getEntry());
|
||||
fetchTags(user, status);
|
||||
statuses.forEach(s -> s.setMarkable(true));
|
||||
if (includeContent) {
|
||||
fetchTags(user, statuses);
|
||||
}
|
||||
return lazyLoadContent(includeContent, statuses);
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
private JPAQuery<FeedEntry> buildQuery(User user, FeedSubscription sub, boolean unreadOnly, List<FeedEntryKeyword> keywords,
|
||||
Instant newerThan, int offset, int limit, ReadingOrder order, FeedEntryStatus last, String tag, Long minEntryId,
|
||||
Long maxEntryId) {
|
||||
public List<FeedEntryStatus> findBySubscriptions(User user, List<FeedSubscription> subs, boolean unreadOnly,
|
||||
List<FeedEntryKeyword> keywords, Instant newerThan, int offset, int limit, ReadingOrder order, boolean includeContent,
|
||||
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)) {
|
||||
query.join(entry.content, content);
|
||||
|
||||
for (FeedEntryKeyword keyword : keywords) {
|
||||
BooleanBuilder or = new BooleanBuilder();
|
||||
or.or(content.content.containsIgnoreCase(keyword.getKeyword()));
|
||||
or.or(content.title.containsIgnoreCase(keyword.getKeyword()));
|
||||
or.or(CONTENT.content.containsIgnoreCase(keyword.getKeyword()));
|
||||
or.or(CONTENT.title.containsIgnoreCase(keyword.getKeyword()));
|
||||
if (keyword.getMode() == Mode.EXCLUDE) {
|
||||
or.not();
|
||||
}
|
||||
query.where(or);
|
||||
}
|
||||
}
|
||||
query.leftJoin(entry.statuses, status).on(status.subscription.id.eq(sub.getId()));
|
||||
|
||||
if (unreadOnly && tag == null) {
|
||||
BooleanBuilder or = new BooleanBuilder();
|
||||
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));
|
||||
}
|
||||
query.where(buildUnreadPredicate());
|
||||
}
|
||||
|
||||
if (tag != null) {
|
||||
BooleanBuilder and = new BooleanBuilder();
|
||||
and.and(entryTag.user.id.eq(user.getId()));
|
||||
and.and(entryTag.name.eq(tag));
|
||||
query.join(entry.tags, entryTag).on(and);
|
||||
and.and(TAG.user.id.eq(user.getId()));
|
||||
and.and(TAG.name.eq(tag));
|
||||
query.join(ENTRY.tags, TAG).on(and);
|
||||
}
|
||||
|
||||
if (newerThan != null) {
|
||||
query.where(entry.inserted.goe(newerThan));
|
||||
query.where(ENTRY.inserted.goe(newerThan));
|
||||
}
|
||||
|
||||
if (minEntryId != null) {
|
||||
query.where(entry.id.gt(minEntryId));
|
||||
query.where(ENTRY.id.gt(minEntryId));
|
||||
}
|
||||
|
||||
if (maxEntryId != null) {
|
||||
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()));
|
||||
}
|
||||
query.where(ENTRY.id.lt(maxEntryId));
|
||||
}
|
||||
|
||||
if (order != null) {
|
||||
if (order == ReadingOrder.asc) {
|
||||
query.orderBy(entry.updated.asc(), entry.id.asc());
|
||||
query.orderBy(ENTRY.published.asc(), ENTRY.id.asc());
|
||||
} 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());
|
||||
return query;
|
||||
}
|
||||
|
||||
public List<FeedEntryStatus> findBySubscriptions(User user, List<FeedSubscription> subs, boolean unreadOnly,
|
||||
List<FeedEntryKeyword> keywords, Instant newerThan, int offset, int limit, ReadingOrder order, boolean includeContent,
|
||||
boolean onlyIds, String tag, Long minEntryId, Long maxEntryId) {
|
||||
int capacity = offset + limit;
|
||||
|
||||
Comparator<FeedEntryStatus> comparator = order == ReadingOrder.desc ? STATUS_COMPARATOR_DESC : STATUS_COMPARATOR_ASC;
|
||||
|
||||
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> statuses = new ArrayList<>();
|
||||
List<Tuple> tuples = query.fetch();
|
||||
for (Tuple tuple : tuples) {
|
||||
FeedEntry e = tuple.get(ENTRY);
|
||||
FeedEntryStatus s = tuple.get(STATUS);
|
||||
for (FeedSubscription sub : subsByFeedId.get(e.getFeed().getId())) {
|
||||
statuses.add(handleStatus(user, s, sub, e));
|
||||
}
|
||||
}
|
||||
|
||||
List<FeedEntryStatus> placeholders = fssl.asList();
|
||||
int size = placeholders.size();
|
||||
if (size < offset) {
|
||||
return new ArrayList<>();
|
||||
if (includeContent) {
|
||||
fetchTags(user, statuses);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
public UnreadCount getUnreadCount(User user, FeedSubscription subscription) {
|
||||
UnreadCount uc = null;
|
||||
JPAQuery<FeedEntry> query = buildQuery(user, subscription, true, null, null, -1, -1, null, null, null, null, null);
|
||||
List<Tuple> tuples = query.select(entry.count(), entry.updated.max()).fetch();
|
||||
for (Tuple tuple : tuples) {
|
||||
Long count = tuple.get(entry.count());
|
||||
Instant updated = tuple.get(entry.updated.max());
|
||||
uc = new UnreadCount(subscription.getId(), count == null ? 0 : count, updated);
|
||||
}
|
||||
return uc;
|
||||
public UnreadCount getUnreadCount(FeedSubscription sub) {
|
||||
JPAQuery<Tuple> query = query().select(ENTRY.count(), ENTRY.published.max())
|
||||
.from(ENTRY)
|
||||
.leftJoin(ENTRY.statuses, STATUS)
|
||||
.on(STATUS.subscription.eq(sub))
|
||||
.where(ENTRY.feed.eq(sub.getFeed()))
|
||||
.where(buildUnreadPredicate());
|
||||
|
||||
Tuple tuple = query.fetchOne();
|
||||
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) {
|
||||
if (includeContent) {
|
||||
for (FeedEntryStatus status : results) {
|
||||
Models.initialize(status.getSubscription().getFeed());
|
||||
Models.initialize(status.getEntry().getContent());
|
||||
}
|
||||
private BooleanBuilder buildUnreadPredicate() {
|
||||
BooleanBuilder or = new BooleanBuilder();
|
||||
or.or(STATUS.read.isNull());
|
||||
or.or(STATUS.read.isFalse());
|
||||
|
||||
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) {
|
||||
List<Long> ids = query().select(status.id)
|
||||
.from(status)
|
||||
.where(status.entryInserted.lt(olderThan), status.starred.isFalse())
|
||||
List<Long> ids = query().select(STATUS.id)
|
||||
.from(STATUS)
|
||||
.where(STATUS.entryInserted.lt(olderThan), STATUS.starred.isFalse())
|
||||
.limit(limit)
|
||||
.fetch();
|
||||
return deleteQuery(status).where(status.id.in(ids)).execute();
|
||||
return deleteQuery(STATUS).where(STATUS.id.in(ids)).execute();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.commafeed.backend.dao;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.hibernate.SessionFactory;
|
||||
|
||||
@@ -15,7 +17,7 @@ import jakarta.inject.Singleton;
|
||||
@Singleton
|
||||
public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> {
|
||||
|
||||
private final QFeedEntryTag tag = QFeedEntryTag.feedEntryTag;
|
||||
private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag;
|
||||
|
||||
@Inject
|
||||
public FeedEntryTagDAO(SessionFactory sessionFactory) {
|
||||
@@ -23,10 +25,18 @@ public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> {
|
||||
}
|
||||
|
||||
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) {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,16 @@ package com.commafeed.backend.dao;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
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.Feed;
|
||||
@@ -22,54 +29,79 @@ import jakarta.inject.Singleton;
|
||||
@Singleton
|
||||
public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
|
||||
|
||||
private final QFeedSubscription sub = QFeedSubscription.feedSubscription;
|
||||
private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription;
|
||||
|
||||
private final SessionFactory sessionFactory;
|
||||
|
||||
@Inject
|
||||
public FeedSubscriptionDAO(SessionFactory 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) {
|
||||
List<FeedSubscription> subs = query().selectFrom(sub)
|
||||
.where(sub.user.eq(user), sub.id.eq(id))
|
||||
.leftJoin(sub.feed)
|
||||
List<FeedSubscription> subs = query().selectFrom(SUBSCRIPTION)
|
||||
.where(SUBSCRIPTION.user.eq(user), SUBSCRIPTION.id.eq(id))
|
||||
.leftJoin(SUBSCRIPTION.feed)
|
||||
.fetchJoin()
|
||||
.leftJoin(sub.category)
|
||||
.leftJoin(SUBSCRIPTION.category)
|
||||
.fetchJoin()
|
||||
.fetch();
|
||||
return initRelations(Iterables.getFirst(subs, null));
|
||||
}
|
||||
|
||||
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) {
|
||||
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));
|
||||
}
|
||||
|
||||
public List<FeedSubscription> findAll(User user) {
|
||||
List<FeedSubscription> subs = query().selectFrom(sub)
|
||||
.where(sub.user.eq(user))
|
||||
.leftJoin(sub.feed)
|
||||
List<FeedSubscription> subs = query().selectFrom(SUBSCRIPTION)
|
||||
.where(SUBSCRIPTION.user.eq(user))
|
||||
.leftJoin(SUBSCRIPTION.feed)
|
||||
.fetchJoin()
|
||||
.leftJoin(sub.category)
|
||||
.leftJoin(SUBSCRIPTION.category)
|
||||
.fetchJoin()
|
||||
.fetch();
|
||||
return initRelations(subs);
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
query.where(sub.category.isNull());
|
||||
query.where(SUBSCRIPTION.category.isNull());
|
||||
} else {
|
||||
query.where(sub.category.eq(category));
|
||||
query.where(SUBSCRIPTION.category.eq(category));
|
||||
}
|
||||
return initRelations(query.fetch());
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import jakarta.inject.Singleton;
|
||||
@Singleton
|
||||
public class UserDAO extends GenericDAO<User> {
|
||||
|
||||
private final QUser user = QUser.user;
|
||||
private static final QUser USER = QUser.user;
|
||||
|
||||
@Inject
|
||||
public UserDAO(SessionFactory sessionFactory) {
|
||||
@@ -19,18 +19,18 @@ public class UserDAO extends GenericDAO<User> {
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
return query().selectFrom(user).where(user.email.equalsIgnoreCase(email)).fetchOne();
|
||||
return query().selectFrom(USER).where(USER.email.equalsIgnoreCase(email)).fetchOne();
|
||||
}
|
||||
|
||||
public long count() {
|
||||
return query().select(user.count()).from(user).fetchOne();
|
||||
return query().select(USER.count()).from(USER).fetchOne();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import jakarta.inject.Singleton;
|
||||
@Singleton
|
||||
public class UserRoleDAO extends GenericDAO<UserRole> {
|
||||
|
||||
private final QUserRole role = QUserRole.userRole;
|
||||
private static final QUserRole ROLE = QUserRole.userRole;
|
||||
|
||||
@Inject
|
||||
public UserRoleDAO(SessionFactory sessionFactory) {
|
||||
@@ -25,11 +25,11 @@ public class UserRoleDAO extends GenericDAO<UserRole> {
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import jakarta.inject.Singleton;
|
||||
@Singleton
|
||||
public class UserSettingsDAO extends GenericDAO<UserSettings> {
|
||||
|
||||
private final QUserSettings settings = QUserSettings.userSettings;
|
||||
private static final QUserSettings SETTINGS = QUserSettings.userSettings;
|
||||
|
||||
@Inject
|
||||
public UserSettingsDAO(SessionFactory sessionFactory) {
|
||||
@@ -20,6 +20,6 @@ public class UserSettingsDAO extends GenericDAO<UserSettings> {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.commafeed.backend.feed;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
@@ -103,9 +102,12 @@ public class FeedRefreshUpdater {
|
||||
if (locked1 && locked2) {
|
||||
processed = true;
|
||||
inserted = unitOfWork.call(() -> {
|
||||
Instant now = Instant.now();
|
||||
FeedEntry feedEntry = feedEntryService.findOrCreate(feed, entry);
|
||||
boolean newEntry = !feedEntry.getInserted().isBefore(now);
|
||||
boolean newEntry = false;
|
||||
FeedEntry feedEntry = feedEntryService.find(feed, entry);
|
||||
if (feedEntry == null) {
|
||||
feedEntry = feedEntryService.create(feed, entry);
|
||||
newEntry = true;
|
||||
}
|
||||
if (newEntry) {
|
||||
entryInserted.mark();
|
||||
for (FeedSubscription sub : subscriptions) {
|
||||
@@ -118,10 +120,10 @@ public class FeedRefreshUpdater {
|
||||
return newEntry;
|
||||
});
|
||||
} else {
|
||||
log.error("lock timeout for " + feed.getUrl() + " - " + key1);
|
||||
log.error("lock timeout for {} - {}", feed.getUrl(), key1);
|
||||
}
|
||||
} 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 {
|
||||
if (locked1) {
|
||||
lock1.unlock();
|
||||
@@ -168,11 +170,10 @@ public class FeedRefreshUpdater {
|
||||
if (subscriptions == null) {
|
||||
feed.setMessage("No new entries found");
|
||||
} else if (inserted > 0) {
|
||||
log.debug("inserted {} entries for feed {}", inserted, feed.getId());
|
||||
List<User> users = subscriptions.stream().map(FeedSubscription::getUser).toList();
|
||||
cache.invalidateUnreadCount(subscriptions.toArray(new FeedSubscription[0]));
|
||||
cache.invalidateUserRootCategory(users.toArray(new User[0]));
|
||||
|
||||
notifyOverWebsocket(unreadCountBySubscription);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +188,8 @@ public class FeedRefreshUpdater {
|
||||
|
||||
unitOfWork.run(() -> feedService.save(feed));
|
||||
|
||||
notifyOverWebsocket(unreadCountBySubscription);
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ public class FeedRefreshWorker {
|
||||
Integer maxEntriesAgeDays = config.getApplicationSettings().getMaxEntriesAgeDays();
|
||||
if (maxEntriesAgeDays > 0) {
|
||||
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();
|
||||
|
||||
@@ -139,7 +139,7 @@ public class FeedUtils {
|
||||
try {
|
||||
return new URL(new URL(baseUrl), relativeUrl).toString();
|
||||
} catch (MalformedURLException e) {
|
||||
log.debug("could not parse url : " + e.getMessage(), e);
|
||||
log.debug("could not parse url : {}", e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ public class FeedParser {
|
||||
String title = feed.getTitle();
|
||||
String link = feed.getLink();
|
||||
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);
|
||||
if (lastPublishedDate == null || lastEntryDate != null && lastPublishedDate.isBefore(lastEntryDate)) {
|
||||
lastPublishedDate = lastEntryDate;
|
||||
@@ -123,13 +123,13 @@ public class FeedParser {
|
||||
url = guid;
|
||||
}
|
||||
|
||||
Instant updated = buildEntryUpdateDate(item);
|
||||
Instant publishedDate = buildEntryPublishedDate(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;
|
||||
}
|
||||
|
||||
@@ -154,10 +154,10 @@ public class FeedParser {
|
||||
return new Enclosure(enclosure.getUrl(), enclosure.getType());
|
||||
}
|
||||
|
||||
private Instant buildEntryUpdateDate(SyndEntry item) {
|
||||
Date date = item.getUpdatedDate();
|
||||
private Instant buildEntryPublishedDate(SyndEntry item) {
|
||||
Date date = item.getPublishedDate();
|
||||
if (date == null) {
|
||||
date = item.getPublishedDate();
|
||||
date = item.getUpdatedDate();
|
||||
}
|
||||
return toValidInstant(date, true);
|
||||
}
|
||||
@@ -262,7 +262,7 @@ public class FeedParser {
|
||||
|
||||
SummaryStatistics stats = new SummaryStatistics();
|
||||
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);
|
||||
}
|
||||
return (long) stats.getMean();
|
||||
|
||||
@@ -5,7 +5,7 @@ import java.util.List;
|
||||
|
||||
public record FeedParserResult(String title, String link, Instant lastPublishedDate, Long averageEntryInterval, Instant lastEntryDate,
|
||||
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) {
|
||||
|
||||
@@ -37,11 +37,18 @@ public class FeedEntry extends AbstractModel {
|
||||
@Column(length = 2048)
|
||||
private String url;
|
||||
|
||||
/**
|
||||
* the moment the entry was inserted in the database
|
||||
*/
|
||||
@Column
|
||||
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)
|
||||
private Set<FeedEntryStatus> statuses;
|
||||
|
||||
@@ -50,8 +50,8 @@ public class FeedEntryStatus extends AbstractModel {
|
||||
@Column
|
||||
private Instant entryInserted;
|
||||
|
||||
@Column
|
||||
private Instant entryUpdated;
|
||||
@Column(name = "entryUpdated")
|
||||
private Instant entryPublished;
|
||||
|
||||
public FeedEntryStatus() {
|
||||
|
||||
@@ -62,7 +62,7 @@ public class FeedEntryStatus extends AbstractModel {
|
||||
this.subscription = subscription;
|
||||
this.entry = entry;
|
||||
this.entryInserted = entry.getInserted();
|
||||
this.entryUpdated = entry.getUpdated();
|
||||
this.entryPublished = entry.getPublished();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -35,13 +35,9 @@ public class RSSRDF10Parser extends RSS10Parser {
|
||||
|
||||
ok = defaultNS != null && defaultNS.equals(getRDFNamespace());
|
||||
if (ok) {
|
||||
if (additionalNSs == null) {
|
||||
ok = false;
|
||||
} else {
|
||||
ok = false;
|
||||
for (int i = 0; !ok && i < additionalNSs.size(); i++) {
|
||||
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;
|
||||
|
||||
@@ -35,18 +35,21 @@ public class FeedEntryService {
|
||||
private final FeedEntryFilteringService feedEntryFilteringService;
|
||||
private final CacheService cache;
|
||||
|
||||
/**
|
||||
* this is NOT thread-safe
|
||||
*/
|
||||
public FeedEntry findOrCreate(Feed feed, Entry entry) {
|
||||
String guid = FeedUtils.truncate(entry.guid(), 2048);
|
||||
public FeedEntry find(Feed feed, Entry entry) {
|
||||
String guidHash = Digests.sha1Hex(entry.guid());
|
||||
FeedEntry existing = feedEntryDAO.findExisting(guidHash, feed);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
return feedEntryDAO.findExisting(guidHash, feed);
|
||||
}
|
||||
|
||||
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);
|
||||
return feedEntry;
|
||||
}
|
||||
@@ -68,19 +71,6 @@ public class FeedEntryService {
|
||||
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) {
|
||||
FeedEntry entry = feedEntryDAO.findById(entryId);
|
||||
if (entry == null) {
|
||||
@@ -121,7 +111,7 @@ public class FeedEntryService {
|
||||
public void markSubscriptionEntries(User user, List<FeedSubscription> subscriptions, Instant olderThan, Instant insertedBefore,
|
||||
List<FeedEntryKeyword> keywords) {
|
||||
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);
|
||||
cache.invalidateUnreadCount(subscriptions.toArray(new FeedSubscription[0]));
|
||||
cache.invalidateUserRootCategory(user);
|
||||
@@ -133,8 +123,8 @@ public class FeedEntryService {
|
||||
}
|
||||
|
||||
private void markList(List<FeedEntryStatus> statuses, Instant olderThan, Instant insertedBefore) {
|
||||
List<FeedEntryStatus> statusesToMark = statuses.stream().filter(s -> {
|
||||
Instant entryDate = s.getEntry().getUpdated();
|
||||
List<FeedEntryStatus> statusesToMark = statuses.stream().filter(FeedEntryStatus::isMarkable).filter(s -> {
|
||||
Instant entryDate = s.getEntry().getPublished();
|
||||
return olderThan == null || entryDate == null || entryDate.isBefore(olderThan);
|
||||
}).filter(s -> {
|
||||
Instant insertedDate = s.getEntry().getInserted();
|
||||
|
||||
@@ -23,11 +23,9 @@ import com.commafeed.frontend.model.UnreadCount;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
|
||||
@Singleton
|
||||
public class FeedSubscriptionService {
|
||||
|
||||
@@ -39,6 +37,22 @@ public class FeedSubscriptionService {
|
||||
private final CacheService cache;
|
||||
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) {
|
||||
return subscribe(user, url, title, null, 0);
|
||||
}
|
||||
@@ -83,7 +97,6 @@ public class FeedSubscriptionService {
|
||||
sub.setTitle(FeedUtils.truncate(title, 128));
|
||||
feedSubscriptionDAO.saveOrUpdate(sub);
|
||||
|
||||
feedRefreshEngine.refreshImmediately(feed);
|
||||
cache.invalidateUserRootCategory(user);
|
||||
return sub.getId();
|
||||
}
|
||||
@@ -119,14 +132,14 @@ public class FeedSubscriptionService {
|
||||
}
|
||||
|
||||
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);
|
||||
if (count == null) {
|
||||
log.debug("unread count cache miss for {}", Models.getId(sub));
|
||||
count = feedEntryStatusDAO.getUnreadCount(user, sub);
|
||||
count = feedEntryStatusDAO.getUnreadCount(sub);
|
||||
cache.setUnreadCount(sub, count);
|
||||
}
|
||||
return count;
|
||||
|
||||
@@ -17,6 +17,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@Slf4j
|
||||
public class H2MigrationService {
|
||||
|
||||
private static final String H2_FILE_SUFFIX = ".mv.db";
|
||||
|
||||
public void migrateIfNeeded(Path path, String user, String password) {
|
||||
if (Files.notExists(path)) {
|
||||
return;
|
||||
@@ -54,9 +56,10 @@ public class H2MigrationService {
|
||||
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);
|
||||
|
||||
Path scriptPath = path.resolveSibling("script-" + System.currentTimeMillis() + ".sql");
|
||||
Path newVersionPath = path.resolveSibling(path.getFileName() + "." + getPatchVersion(toVersion) + ".mv.db");
|
||||
Path oldVersionBackupPath = path.resolveSibling(path.getFileName() + "." + getPatchVersion(fromVersion) + ".backup");
|
||||
Path scriptPath = path.resolveSibling("script-%d.sql".formatted(System.currentTimeMillis()));
|
||||
Path newVersionPath = path.resolveSibling("%s.%s%s".formatted(StringUtils.removeEnd(path.getFileName().toString(), H2_FILE_SUFFIX),
|
||||
getPatchVersion(toVersion), H2_FILE_SUFFIX));
|
||||
Path oldVersionBackupPath = path.resolveSibling("%s.%s.backup".formatted(path.getFileName(), getPatchVersion(fromVersion)));
|
||||
|
||||
Files.deleteIfExists(scriptPath);
|
||||
Files.deleteIfExists(newVersionPath);
|
||||
|
||||
@@ -3,11 +3,9 @@ package com.commafeed.backend.service.internal;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
import com.commafeed.CommaFeedConfiguration;
|
||||
import com.commafeed.backend.dao.UnitOfWork;
|
||||
import com.commafeed.backend.dao.UserDAO;
|
||||
import com.commafeed.backend.model.User;
|
||||
import com.commafeed.backend.service.FeedSubscriptionService;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
@@ -18,9 +16,7 @@ import lombok.RequiredArgsConstructor;
|
||||
public class PostLoginActivities {
|
||||
|
||||
private final UserDAO userDAO;
|
||||
private final FeedSubscriptionService feedSubscriptionService;
|
||||
private final UnitOfWork unitOfWork;
|
||||
private final CommaFeedConfiguration config;
|
||||
|
||||
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
|
||||
@@ -28,19 +24,6 @@ public class PostLoginActivities {
|
||||
Instant lastLogin = user.getLastLogin();
|
||||
if (lastLogin == null || ChronoUnit.MINUTES.between(lastLogin, now) >= 30) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ public class Entry implements Serializable {
|
||||
entry.setRead(status.isRead());
|
||||
entry.setStarred(status.isStarred());
|
||||
entry.setMarkable(status.isMarkable());
|
||||
entry.setDate(feedEntry.getUpdated());
|
||||
entry.setDate(feedEntry.getPublished());
|
||||
entry.setInsertedDate(feedEntry.getInserted());
|
||||
entry.setUrl(feedEntry.getUrl());
|
||||
entry.setFeedName(sub.getTitle());
|
||||
|
||||
@@ -109,7 +109,6 @@ public class CategoryREST {
|
||||
@Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
|
||||
@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,
|
||||
@Parameter(description = "return only entry ids") @DefaultValue("false") @QueryParam("onlyIds") boolean onlyIds,
|
||||
@Parameter(
|
||||
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) {
|
||||
@@ -143,7 +142,7 @@ public class CategoryREST {
|
||||
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
|
||||
removeExcludedSubscriptions(subs, excludedIds);
|
||||
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) {
|
||||
entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled()));
|
||||
@@ -151,7 +150,7 @@ public class CategoryREST {
|
||||
|
||||
} else if (STARRED.equals(id)) {
|
||||
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) {
|
||||
entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled()));
|
||||
}
|
||||
@@ -162,7 +161,7 @@ public class CategoryREST {
|
||||
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories);
|
||||
removeExcludedSubscriptions(subs, excludedIds);
|
||||
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) {
|
||||
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 = "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(
|
||||
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) {
|
||||
|
||||
Response response = getCategoryEntries(user, id, readType, newerThan, offset, limit, order, keywords, onlyIds,
|
||||
excludedSubscriptionIds, tag);
|
||||
Response response = getCategoryEntries(user, id, readType, newerThan, offset, limit, order, keywords, excludedSubscriptionIds, tag);
|
||||
if (response.getStatus() != Status.OK.getStatusCode()) {
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -143,10 +143,8 @@ public class FeedREST {
|
||||
@Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
|
||||
@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 = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
|
||||
@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,
|
||||
@Parameter(description = "return only entry ids") @DefaultValue("false") @QueryParam("onlyIds") boolean onlyIds) {
|
||||
@Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, @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) {
|
||||
|
||||
Preconditions.checkNotNull(id);
|
||||
Preconditions.checkNotNull(readType);
|
||||
@@ -174,7 +172,7 @@ public class FeedREST {
|
||||
entries.setFeedLink(subscription.getFeed().getLink());
|
||||
|
||||
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) {
|
||||
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 = "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 = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
|
||||
@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,
|
||||
@Parameter(description = "return only entry ids") @DefaultValue("false") @QueryParam("onlyIds") boolean onlyIds) {
|
||||
@Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, @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) {
|
||||
|
||||
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()) {
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ public class FeverREST {
|
||||
.map(Feed::getLastUpdated)
|
||||
.filter(Objects::nonNull)
|
||||
.max(Comparator.naturalOrder())
|
||||
.map(d -> d.getEpochSecond())
|
||||
.map(Instant::getEpochSecond)
|
||||
.orElse(0L);
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ public class FeverREST {
|
||||
|
||||
private List<Long> buildUnreadItemIds(User user, List<FeedSubscription> subscriptions) {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ public class FeverREST {
|
||||
|
||||
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,
|
||||
ReadingOrder.desc, false, false, null, sinceId, maxId);
|
||||
ReadingOrder.desc, false, null, sinceId, maxId);
|
||||
return statuses.stream().map(this::mapStatus).toList();
|
||||
}
|
||||
|
||||
@@ -295,7 +295,7 @@ public class FeverREST {
|
||||
i.setUrl(s.getEntry().getUrl());
|
||||
i.setSaved(s.isStarred());
|
||||
i.setRead(s.isRead());
|
||||
i.setCreatedOnTime(s.getEntryUpdated().getEpochSecond());
|
||||
i.setCreatedOnTime(s.getEntryPublished().getEpochSecond());
|
||||
return i;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ public class NextUnreadServlet extends HttpServlet {
|
||||
if (StringUtils.isBlank(categoryId) || CategoryREST.ALL.equals(categoryId)) {
|
||||
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user.get());
|
||||
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);
|
||||
} else {
|
||||
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<FeedSubscription> subscriptions = feedSubscriptionDAO.findByCategories(user.get(), children);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public class WebSocketSessions {
|
||||
public void sendMessage(User user, String text) {
|
||||
Set<Session> userSessions = sessions.get(user.getId());
|
||||
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) {
|
||||
if (userSession.isOpen()) {
|
||||
userSession.getAsyncRemote().sendText(text);
|
||||
|
||||
@@ -1,23 +1,100 @@
|
||||
package com.commafeed;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.sql.Connection;
|
||||
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 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.commafeed.CommaFeedConfiguration.CacheType;
|
||||
|
||||
import io.dropwizard.testing.ConfigOverride;
|
||||
import io.dropwizard.testing.ResourceHelpers;
|
||||
import io.dropwizard.testing.junit5.DropwizardAppExtension;
|
||||
import redis.clients.jedis.Jedis;
|
||||
import redis.clients.jedis.JedisPool;
|
||||
|
||||
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() {
|
||||
super(CommaFeedApplication.class, ResourceHelpers.resourceFilePath("config.test.yml"),
|
||||
ConfigOverride.config("server.applicationConnectors[0].port", String.valueOf(PortFactory.findFreePort())));
|
||||
super(CommaFeedApplication.class, ResourceHelpers.resourceFilePath("config.test.yml"), CONFIG_OVERRIDES);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -27,10 +104,22 @@ public class CommaFeedDropwizardAppExtension extends DropwizardAppExtension<Comm
|
||||
// clean database after each test
|
||||
DataSource dataSource = getConfiguration().getDataSourceFactory().build(new MetricRegistry(), "cleanup");
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
connection.prepareStatement("DROP ALL OBJECTS").executeUpdate();
|
||||
for (String statement : DROP_ALL_STATEMENTS) {
|
||||
connection.prepareStatement(statement).executeUpdate();
|
||||
}
|
||||
} catch (SQLException 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) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ public abstract class PlaywrightTestBase {
|
||||
}
|
||||
|
||||
protected void customizeNewContextOptions(NewContextOptions options) {
|
||||
// override in subclasses to customize the browser context
|
||||
}
|
||||
|
||||
protected static class SaveArtifactsOnTestFailed implements TestWatcher, BeforeEachCallback {
|
||||
|
||||
@@ -10,7 +10,6 @@ import org.apache.commons.io.IOUtils;
|
||||
import org.awaitility.Awaitility;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.glassfish.jersey.client.JerseyClientBuilder;
|
||||
import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;
|
||||
import org.glassfish.jersey.media.multipart.MultiPartFeature;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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 final CommaFeedDropwizardAppExtension extension = buildExtension();
|
||||
private final CommaFeedDropwizardAppExtension extension = new CommaFeedDropwizardAppExtension() {
|
||||
@Override
|
||||
protected JerseyClientBuilder clientBuilder() {
|
||||
return configureClientBuilder(super.clientBuilder().register(MultiPartFeature.class));
|
||||
}
|
||||
};
|
||||
|
||||
private Client client;
|
||||
|
||||
@@ -54,13 +58,8 @@ public abstract class BaseIT {
|
||||
|
||||
private MockServerClient mockServerClient;
|
||||
|
||||
protected CommaFeedDropwizardAppExtension buildExtension() {
|
||||
return new CommaFeedDropwizardAppExtension() {
|
||||
@Override
|
||||
protected JerseyClientBuilder clientBuilder() {
|
||||
return super.clientBuilder().register(HttpAuthenticationFeature.basic("admin", "admin")).register(MultiPartFeature.class);
|
||||
}
|
||||
};
|
||||
protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) {
|
||||
return base;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
|
||||
@@ -6,7 +6,6 @@ import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.commafeed.CommaFeedDropwizardAppExtension;
|
||||
import com.commafeed.frontend.model.Entries;
|
||||
import com.commafeed.frontend.model.UserModel;
|
||||
import com.commafeed.frontend.model.request.ProfileModificationRequest;
|
||||
@@ -18,12 +17,6 @@ import jakarta.ws.rs.core.Response;
|
||||
|
||||
class SecurityIT extends BaseIT {
|
||||
|
||||
@Override
|
||||
protected CommaFeedDropwizardAppExtension buildExtension() {
|
||||
// override so we don't add http basic auth
|
||||
return new CommaFeedDropwizardAppExtension();
|
||||
}
|
||||
|
||||
@Test
|
||||
void notLoggedIn() {
|
||||
try (Response response = getClient().target(getApiBaseUrl() + "user/profile").request().get()) {
|
||||
|
||||
@@ -10,6 +10,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
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.Test;
|
||||
|
||||
@@ -28,6 +30,11 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@Slf4j
|
||||
class WebSocketIT extends BaseIT {
|
||||
|
||||
@Override
|
||||
protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) {
|
||||
return base.register(HttpAuthenticationFeature.basic("admin", "admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionClosedIfNotLoggedIn() throws DeploymentException, IOException {
|
||||
AtomicBoolean connected = new AtomicBoolean();
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.commafeed.integration.rest;
|
||||
import java.util.Arrays;
|
||||
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.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -17,6 +19,11 @@ import jakarta.ws.rs.client.Entity;
|
||||
|
||||
class AdminIT extends BaseIT {
|
||||
|
||||
@Override
|
||||
protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) {
|
||||
return base.register(HttpAuthenticationFeature.basic("admin", "admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getApplicationSettings() {
|
||||
ApplicationSettings settings = getClient().target(getApiBaseUrl() + "admin/settings").request().get(ApplicationSettings.class);
|
||||
|
||||
@@ -13,6 +13,8 @@ import org.apache.commons.lang3.StringUtils;
|
||||
import org.awaitility.Awaitility;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
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.file.StreamDataBodyPart;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
@@ -34,6 +36,11 @@ import jakarta.ws.rs.core.Response;
|
||||
|
||||
class FeedIT extends BaseIT {
|
||||
|
||||
@Override
|
||||
protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) {
|
||||
return base.register(HttpAuthenticationFeature.basic("admin", "admin"));
|
||||
}
|
||||
|
||||
@Nested
|
||||
class Fetch {
|
||||
@Test
|
||||
@@ -105,10 +112,11 @@ class FeedIT extends BaseIT {
|
||||
|
||||
@Test
|
||||
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());
|
||||
markFeedEntries(subscriptionId, null, insertedBefore);
|
||||
markFeedEntries(subscriptionId, null, threshold);
|
||||
Assertions.assertTrue(getFeedEntries(subscriptionId).getEntries().stream().noneMatch(Entry::isRead));
|
||||
}
|
||||
|
||||
@@ -116,9 +124,10 @@ class FeedIT extends BaseIT {
|
||||
void markInsertedBeforeAfterSubscription() {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -137,26 +146,29 @@ class FeedIT extends BaseIT {
|
||||
void refresh() {
|
||||
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();
|
||||
request.setId(subscriptionId);
|
||||
getClient().target(getApiBaseUrl() + "feed/refresh").request().post(Entity.json(request), Void.TYPE);
|
||||
|
||||
Awaitility.await()
|
||||
.atMost(Duration.ofSeconds(15))
|
||||
.until(() -> getSubscription(subscriptionId), f -> f.getLastRefresh().isAfter(now));
|
||||
.until(() -> getSubscription(subscriptionId), f -> f.getLastRefresh().isAfter(threshold));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshAll() {
|
||||
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);
|
||||
|
||||
Awaitility.await()
|
||||
.atMost(Duration.ofSeconds(15))
|
||||
.until(() -> getSubscription(subscriptionId), f -> f.getLastRefresh().isAfter(now));
|
||||
.until(() -> getSubscription(subscriptionId), f -> f.getLastRefresh().isAfter(threshold));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -18,6 +20,11 @@ class FeverIT extends BaseIT {
|
||||
private Long userId;
|
||||
private String apiKey;
|
||||
|
||||
@Override
|
||||
protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) {
|
||||
return base.register(HttpAuthenticationFeature.basic("admin", "admin"));
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void init() {
|
||||
// create api key
|
||||
|
||||
@@ -6,7 +6,7 @@ import org.junit.jupiter.api.Test;
|
||||
import com.commafeed.frontend.model.ServerInfo;
|
||||
import com.commafeed.integration.BaseIT;
|
||||
|
||||
public class ServerIT extends BaseIT {
|
||||
class ServerIT extends BaseIT {
|
||||
|
||||
@Test
|
||||
void getServerInfos() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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.Test;
|
||||
|
||||
@@ -12,6 +14,11 @@ import jakarta.ws.rs.core.Response;
|
||||
|
||||
class CustomCodeIT extends BaseIT {
|
||||
|
||||
@Override
|
||||
protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) {
|
||||
return base.register(HttpAuthenticationFeature.basic("admin", "admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test() {
|
||||
// get settings
|
||||
|
||||
@@ -5,7 +5,6 @@ import org.glassfish.jersey.client.ClientProperties;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.commafeed.CommaFeedDropwizardAppExtension;
|
||||
import com.commafeed.frontend.model.UserModel;
|
||||
import com.commafeed.integration.BaseIT;
|
||||
|
||||
@@ -16,12 +15,6 @@ import jakarta.ws.rs.core.Response;
|
||||
|
||||
class LogoutIT extends BaseIT {
|
||||
|
||||
@Override
|
||||
protected CommaFeedDropwizardAppExtension buildExtension() {
|
||||
// override so we don't add http basic auth
|
||||
return new CommaFeedDropwizardAppExtension();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test() {
|
||||
String cookie = login();
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.commafeed.integration.servlet;
|
||||
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
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.Test;
|
||||
|
||||
@@ -12,6 +14,11 @@ import jakarta.ws.rs.core.Response;
|
||||
|
||||
class NextUnreadIT extends BaseIT {
|
||||
|
||||
@Override
|
||||
protected JerseyClientBuilder configureClientBuilder(JerseyClientBuilder base) {
|
||||
return base.register(HttpAuthenticationFeature.basic("admin", "admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test() {
|
||||
subscribeAndWaitForEntries(getFeedUrl());
|
||||
|
||||
@@ -104,10 +104,6 @@ app:
|
||||
# for PostgreSQL
|
||||
# driverClass is org.postgresql.Driver
|
||||
# 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:
|
||||
driverClass: org.h2.Driver
|
||||
@@ -117,6 +113,9 @@ database:
|
||||
properties:
|
||||
charSet: UTF-8
|
||||
validationQuery: "/* CommaFeed Health Check */ SELECT 1"
|
||||
minSize: 1
|
||||
maxSize: 5
|
||||
initialSize: 1
|
||||
|
||||
server:
|
||||
applicationConnectors:
|
||||
|
||||
@@ -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
13
pom.xml
@@ -5,7 +5,7 @@
|
||||
|
||||
<groupId>com.commafeed</groupId>
|
||||
<artifactId>commafeed</artifactId>
|
||||
<version>4.4.1</version>
|
||||
<version>4.6.0</version>
|
||||
<name>CommaFeed</name>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
@@ -22,6 +22,17 @@
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<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>
|
||||
</plugin>
|
||||
</plugins>
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
"customManagers:biomeVersions"
|
||||
"customManagers:mavenPropertyVersions",
|
||||
"customManagers:biomeVersions",
|
||||
":automergePatch",
|
||||
":automergeBranch",
|
||||
":automergeRequireAllStatusChecks",
|
||||
":maintainLockFilesWeekly"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user