Compare commits

...

52 Commits

Author SHA1 Message Date
renovate[bot]
5e382ed73e Update dependency @rolldown/plugin-babel to v0.3.0 2026-11-01 15:13:03 +00:00
Jérémie Panzer
93edb47dca Merge pull request #2360 from Athou/renovate/linguijs-monorepo
Update linguijs monorepo to v6.11.0
2026-11-01 16:02:26 +01:00
renovate[bot]
801680e373 Update linguijs monorepo to v6.11.0 2026-11-01 14:10:40 +00:00
renovate[bot]
5704e1e1fa Update dependency @biomejs/biome to v2.6.4 2026-11-01 08:42:11 +00:00
Jérémie Panzer
a2a6720cd1 Merge pull request #2358 from Athou/renovate/com.puppycrawl.tools-checkstyle-14.x
Update dependency com.puppycrawl.tools:checkstyle to v14.4.0
2026-11-01 09:41:16 +01:00
renovate[bot]
4f8cddeb54 Update dependency com.puppycrawl.tools:checkstyle to v14.4.0 2026-10-31 21:57:58 +00:00
Athou
90d0846bad apply timeout to all queries 2026-10-31 08:46:39 +01:00
renovate[bot]
698b2f4dfd Update dependency react-router-dom to v7.18.5 2026-10-31 00:27:01 +00:00
renovate[bot]
0053c54ae4 Update dependency react-redux to v9.3.1 2026-10-30 22:37:38 +00:00
Athou
16b6077496 keep using ubuntu-24.04 (#2357) 2026-10-30 23:36:23 +01:00
Athou
edb4d5e178 speed up tree building by looking up unread counts in batches 2026-10-30 23:15:22 +01:00
Athou
4b091fb90b fix "Mark all as read" ignoring active search filter (#2354) 2026-10-30 21:08:27 +01:00
Athou
30c049d10a fix infinite scroll not loading more search results when entries are already read (#2353) 2026-10-30 20:54:56 +01:00
Athou
fa8a447787 prevent moving a category into one of its own descendants (#2355) 2026-10-30 20:51:45 +01:00
Athou
20ec5d3eb9 use mandrel instead of graalvm for better compatibility with quarkus
(cherry picked from commit c020b0ae08936d6f57b16c1b6bdd8aea17981ede)
2026-10-30 20:07:18 +01:00
Jérémie Panzer
796dc88c33 Merge pull request #2356 from Athou/renovate/com.google.googlejavaformat-google-java-format-2.x
Update dependency com.google.googlejavaformat:google-java-format to v2
2026-10-30 19:55:46 +01:00
Athou
f979f5426f reformat using google-java-format 2.0 2026-10-30 19:39:53 +01:00
renovate[bot]
8f9be28002 Update dependency com.google.googlejavaformat:google-java-format to v2 2026-10-30 17:25:44 +00:00
Jérémie Panzer
89845681be Merge pull request #2352 from Athou/renovate/axios-1.x
Update dependency axios to v1.21.0
2026-10-30 13:33:33 +01:00
renovate[bot]
8fdf63dd73 Update dependency axios to v1.21.0 2026-10-30 11:17:25 +00:00
Jérémie Panzer
8f07454270 Merge pull request #2350 from dajiaohuang/feat/2268-feed-settings-icon
Use a settings icon for feed controls
2026-10-30 07:23:37 +01:00
Jérémie Panzer
946d82d8ee Merge pull request #2351 from Athou/renovate/react-querybuilder
Update react-querybuilder to v8.26.0
2026-10-30 07:18:05 +01:00
renovate[bot]
f8d625b651 Update react-querybuilder to v8.26.0 2026-10-30 05:54:28 +00:00
dajiaohuang
eefdd40a02 Use settings icon for feed controls 2026-10-29 19:34:22 +08:00
renovate[bot]
08f2889f1d Update debian:13.7 Docker digest to 2dc419c 2026-10-29 01:59:45 +00:00
renovate[bot]
45453fcf4f Update mantine monorepo to v9.7.4 (#2349)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 01:59:27 +00:00
Jérémie Panzer
ef7bab4841 Merge pull request #2348 from Athou/renovate/node-26.x
Update Node.js to v26
2026-10-28 22:03:31 +01:00
renovate[bot]
ac277f9ca5 Update Node.js to v26 2026-10-28 20:44:59 +00:00
renovate[bot]
44dab0287b Update Node.js to v24.22.2 2026-10-28 19:20:32 +00:00
renovate[bot]
88b75ec0cb Update debian:13.7 Docker digest to 8c4733a 2026-10-28 05:34:10 +00:00
renovate[bot]
cbb45ce68c Update ibm-semeru-runtimes:open-jdk-25.0.4.15-jre Docker digest to c4f8cd5 2026-10-28 03:04:23 +00:00
renovate[bot]
b0c4dbf533 Update dependency @biomejs/biome to v2.6.3 2026-10-27 14:52:39 +00:00
renovate[bot]
1891b63a0e Update ibm-semeru-runtimes:open-jdk-25.0.4.15-jre Docker digest to 1e5bef9 2026-10-27 08:26:53 +00:00
Jérémie Panzer
c420fec8ee Merge pull request #2344 from Athou/renovate/dev.cel-cel-0.x
Update dependency dev.cel:cel to v0.15.0
2026-10-27 09:25:55 +01:00
Athou
2ee2a5dfbe standardCelRuntimeBuilder is deprecated 2026-10-27 07:23:50 +01:00
Jérémie Panzer
a5b98f9ea2 Merge pull request #2345 from Athou/renovate/org.mock-server-mockserver-junit-jupiter-no-dependencies-9.x
Update dependency org.mock-server:mockserver-junit-jupiter-no-dependencies to v9.2.0
2026-10-27 07:20:19 +01:00
renovate[bot]
baedab2960 Update dependency org.mock-server:mockserver-junit-jupiter-no-dependencies to v9.2.0 2026-10-27 02:09:09 +00:00
renovate[bot]
004e18bbb8 Update dependency dev.cel:cel to v0.15.0 2026-10-27 02:09:03 +00:00
Athou
72e364afe9 babel types are no longer needed now that linguijs does not require babel anymore 2026-10-26 20:15:32 +01:00
renovate[bot]
86ff2dfcb2 Update graalvm/setup-graalvm action to v1.6.8 2026-10-26 18:41:05 +00:00
renovate[bot]
69ce73012c Update dependency @babel/types to ^8.0.8 2026-10-26 17:46:09 +00:00
renovate[bot]
f3bd1a2e11 Update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.6.1 2026-10-26 06:38:33 +00:00
renovate[bot]
a819283661 Update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.6.1 2026-10-26 04:33:45 +00:00
renovate[bot]
8255c1e929 Lock file maintenance 2026-10-26 00:53:26 +00:00
renovate[bot]
eb7990f8dc Update linguijs monorepo to v6.10.1 2026-10-25 16:24:22 +00:00
Jérémie Panzer
982476b2d0 Merge pull request #2343 from Athou/renovate/biomejs-biome-2.x
Update dependency @biomejs/biome to v2.6.2
2026-10-24 19:58:21 +02:00
renovate[bot]
3ec653c3b7 Update dependency @biomejs/biome to v2.6.2 2026-10-24 13:30:33 +00:00
renovate[bot]
5dc5b3795e Update dependency vite to v8.3.5 2026-10-24 08:49:46 +00:00
Jérémie Panzer
f5f77a80a3 Merge pull request #2341 from Athou/renovate/docker-login-action-4.x
Update docker/login-action action to v4.7.0
2026-10-23 20:36:02 +02:00
renovate[bot]
1d1099492e Update docker/login-action action to v4.7.0 2026-10-23 18:01:41 +00:00
Jérémie Panzer
ccc2564929 Merge pull request #2340 from Athou/renovate/com.ibm.icu-icu4j-79.x
Update dependency com.ibm.icu:icu4j to v79
2026-10-23 08:55:25 +02:00
renovate[bot]
084574ffd4 Update dependency com.ibm.icu:icu4j to v79 2026-10-22 20:43:24 +00:00
47 changed files with 902 additions and 1176 deletions

View File

@@ -16,7 +16,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [ "ubuntu-latest", "ubuntu-22.04-arm", "windows-latest" ] os: [ "ubuntu-24.04", "ubuntu-22.04-arm", "windows-latest" ]
database: [ "h2", "postgresql", "mysql", "mariadb" ] database: [ "h2", "postgresql", "mysql", "mariadb" ]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
@@ -29,10 +29,10 @@ jobs:
# Setup # Setup
- name: Set up GraalVM - name: Set up GraalVM
uses: graalvm/setup-graalvm@27b360dbb857524eaf0b4c4cef364d38e5b4f173 # v1.6.7 uses: graalvm/setup-graalvm@f70bc8032f90a104502824a54bcb94b6f01715bc # v1.6.8
with: with:
java-version: ${{ env.JAVA_VERSION }} java-version: ${{ env.JAVA_VERSION }}
distribution: "graalvm" distribution: "mandrel"
cache: "maven" cache: "maven"
- name: Set up NodeJS cache - name: Set up NodeJS cache
@@ -74,7 +74,7 @@ jobs:
# Upload artifacts # Upload artifacts
- name: Upload cross-platform app - name: Upload cross-platform app
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: matrix.os == 'ubuntu-latest' # we only need to upload the cross-platform artifact once per database if: matrix.os == 'ubuntu-24.04' # we only need to upload the cross-platform artifact once per database
with: with:
name: commafeed-${{ matrix.database }}-jvm name: commafeed-${{ matrix.database }}-jvm
path: commafeed-server/target/commafeed-*.zip path: commafeed-server/target/commafeed-*.zip
@@ -87,7 +87,7 @@ jobs:
- name: Upload pages - name: Upload pages
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
if: matrix.os == 'ubuntu-latest' && matrix.database == 'h2' # we only need to upload the pages once if: matrix.os == 'ubuntu-24.04' && matrix.database == 'h2' # we only need to upload the pages once
with: with:
path: target/pages path: target/pages
@@ -141,7 +141,7 @@ jobs:
# Docker # Docker
- name: Login to Container Registry - name: Login to Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
if: ${{ env.DOCKERHUB_USERNAME != '' }} if: ${{ env.DOCKERHUB_USERNAME != '' }}
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -17,20 +17,20 @@
"dependencies": { "dependencies": {
"@emotion/react": "11.14.0", "@emotion/react": "11.14.0",
"@fontsource/open-sans": "5.3.0", "@fontsource/open-sans": "5.3.0",
"@lingui/core": "6.10.0", "@lingui/core": "6.11.0",
"@lingui/react": "6.10.0", "@lingui/react": "6.11.0",
"@mantine/core": "9.7.3", "@mantine/core": "9.7.4",
"@mantine/dates": "9.7.3", "@mantine/dates": "9.7.4",
"@mantine/form": "9.7.3", "@mantine/form": "9.7.4",
"@mantine/hooks": "9.7.3", "@mantine/hooks": "9.7.4",
"@mantine/modals": "9.7.3", "@mantine/modals": "9.7.4",
"@mantine/notifications": "9.7.3", "@mantine/notifications": "9.7.4",
"@mantine/spotlight": "9.7.3", "@mantine/spotlight": "9.7.4",
"@monaco-editor/react": "4.7.0", "@monaco-editor/react": "4.7.0",
"@react-querybuilder/mantine": "8.25.0", "@react-querybuilder/mantine": "8.26.0",
"@reduxjs/toolkit": "2.13.0", "@reduxjs/toolkit": "2.13.0",
"@rolldown/plugin-babel": "0.2.4", "@rolldown/plugin-babel": "0.3.0",
"axios": "1.20.0", "axios": "1.21.0",
"dayjs": "1.11.23", "dayjs": "1.11.23",
"escape-string-regexp": "5.0.0", "escape-string-regexp": "5.0.0",
"interweave": "13.1.1", "interweave": "13.1.1",
@@ -43,9 +43,9 @@
"react-draggable": "4.7.2", "react-draggable": "4.7.2",
"react-icons": "5.7.0", "react-icons": "5.7.0",
"react-infinite-scroller": "1.2.6", "react-infinite-scroller": "1.2.6",
"react-querybuilder": "8.25.0", "react-querybuilder": "8.26.0",
"react-redux": "9.3.0", "react-redux": "9.3.1",
"react-router-dom": "7.18.4", "react-router-dom": "7.18.5",
"react-swipeable": "7.0.2", "react-swipeable": "7.0.2",
"style-to-object": "2.0.2", "style-to-object": "2.0.2",
"throttle-debounce": "5.0.2", "throttle-debounce": "5.0.2",
@@ -54,10 +54,9 @@
"websocket-heartbeat-js": "1.1.3" "websocket-heartbeat-js": "1.1.3"
}, },
"devDependencies": { "devDependencies": {
"@babel/types": "^8.0.6", "@biomejs/biome": "2.6.4",
"@biomejs/biome": "2.5.15", "@lingui/cli": "6.11.0",
"@lingui/cli": "6.10.0", "@lingui/vite-plugin": "6.11.0",
"@lingui/vite-plugin": "6.10.0",
"@testing-library/jest-dom": "7.0.1", "@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.8", "@testing-library/user-event": "14.6.8",
@@ -71,7 +70,7 @@
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"jsdom": "30.1.3", "jsdom": "30.1.3",
"typescript": "7.0.2", "typescript": "7.0.2",
"vite": "8.3.4", "vite": "8.3.5",
"vite-plugin-checker": "0.14.5", "vite-plugin-checker": "0.14.5",
"vitest": "5.0.5", "vitest": "5.0.5",
"yaml": "2.9.1" "yaml": "2.9.1"

View File

@@ -13,7 +13,7 @@
<properties> <properties>
<!-- renovate: datasource=node-version depName=node --> <!-- renovate: datasource=node-version depName=node -->
<node.version>v24.22.1</node.version> <node.version>v26.13.0</node.version>
<!-- renovate: datasource=npm depName=npm --> <!-- renovate: datasource=npm depName=npm -->
<npm.version>12.2.1</npm.version> <npm.version>12.2.1</npm.version>
</properties> </properties>

View File

@@ -40,7 +40,7 @@ export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_,
const state = thunkApi.getState() const state = thunkApi.getState()
const { source } = state.entries const { source } = state.entries
const offset = const offset =
state.user.settings?.readingMode === "all" || (source.type === "category" && source.id === "starred") state.user.settings?.readingMode === "all" || state.entries.search || (source.type === "category" && source.id === "starred")
? state.entries.entries.length ? state.entries.entries.length
: state.entries.entries.filter(e => !e.read).length : state.entries.entries.filter(e => !e.read).length
const endpoint = getEndpoint(state.entries.source.type) const endpoint = getEndpoint(state.entries.source.type)
@@ -137,6 +137,7 @@ export const markAllAsReadWithConfirmationIfRequired = createAppAsyncThunk(
const state = thunkApi.getState() const state = thunkApi.getState()
const source = state.entries.source const source = state.entries.source
const entriesTimestamp = state.entries.timestamp ?? Date.now() const entriesTimestamp = state.entries.timestamp ?? Date.now()
const search = state.entries.search
const markAllAsReadConfirmation = state.user.settings?.markAllAsReadConfirmation const markAllAsReadConfirmation = state.user.settings?.markAllAsReadConfirmation
const markAllAsReadNavigateToNextUnread = state.user.settings?.markAllAsReadNavigateToNextUnread const markAllAsReadNavigateToNextUnread = state.user.settings?.markAllAsReadNavigateToNextUnread
@@ -151,6 +152,7 @@ export const markAllAsReadWithConfirmationIfRequired = createAppAsyncThunk(
read: true, read: true,
olderThan: Date.now(), olderThan: Date.now(),
insertedBefore: entriesTimestamp, insertedBefore: entriesTimestamp,
keywords: search,
}, },
}) })
) )

View File

@@ -13,6 +13,7 @@ export function MarkAllAsReadConfirmationDialog() {
const source = useAppSelector(state => state.entries.source) const source = useAppSelector(state => state.entries.source)
const sourceLabel = useAppSelector(state => state.entries.sourceLabel) const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now() const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now()
const search = useAppSelector(state => state.entries.search)
const markAllAsReadNavigateToNextUnread = useAppSelector(state => state.user.settings?.markAllAsReadNavigateToNextUnread) const markAllAsReadNavigateToNextUnread = useAppSelector(state => state.user.settings?.markAllAsReadNavigateToNextUnread)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@@ -26,6 +27,7 @@ export function MarkAllAsReadConfirmationDialog() {
read: true, read: true,
olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000, olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000,
insertedBefore: entriesTimestamp, insertedBefore: entriesTimestamp,
keywords: search,
}, },
}) })
) )

View File

@@ -37,6 +37,9 @@ export function CategoryDetailsPage() {
category = query.result && flattenCategoryTree(query.result.data).find(c => c.id === id) category = query.result && flattenCategoryTree(query.result.data).find(c => c.id === id)
} }
// a category cannot be moved under itself or one of its own descendants
const excludedCategoryIds = category ? flattenCategoryTree(category).map(c => c.id) : [id]
const form = useForm<CategoryModificationRequest>() const form = useForm<CategoryModificationRequest>()
const { setValues } = form const { setValues } = form
@@ -125,7 +128,7 @@ export function CategoryDetailsPage() {
label={<Trans>Parent Category</Trans>} label={<Trans>Parent Category</Trans>}
{...form.getInputProps("parentId")} {...form.getInputProps("parentId")}
clearable clearable
withoutCategoryIds={[id]} withoutCategoryIds={excludedCategoryIds}
/> />
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} /> <NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
</> </>

View File

@@ -2,7 +2,7 @@ import { Trans } from "@lingui/react/macro"
import { ActionIcon, Box, Center, Divider, Group, Title, useMantineTheme } from "@mantine/core" import { ActionIcon, Box, Center, Divider, Group, Title, useMantineTheme } from "@mantine/core"
import { useViewportSize } from "@mantine/hooks" import { useViewportSize } from "@mantine/hooks"
import { useEffect } from "react" import { useEffect } from "react"
import { TbEdit } from "react-icons/tb" import { TbSettings } from "react-icons/tb"
import { useLocation, useParams } from "react-router-dom" import { useLocation, useParams } from "react-router-dom"
import { Constants } from "@/app/constants" import { Constants } from "@/app/constants"
import type { EntrySourceType } from "@/app/entries/slice" import type { EntrySourceType } from "@/app/entries/slice"
@@ -138,7 +138,7 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
</Title> </Title>
)} )}
<ActionIcon onClick={titleClicked} variant="subtle" color={theme.primaryColor}> <ActionIcon onClick={titleClicked} variant="subtle" color={theme.primaryColor}>
<TbEdit size={18} /> <TbSettings size={18} />
</ActionIcon> </ActionIcon>
{showUnreadCount && <UnreadCount unreadCount={unreadCount} showIndicator={hasNewEntries} />} {showUnreadCount && <UnreadCount unreadCount={unreadCount} showIndicator={hasNewEntries} />}
</Group> </Group>

View File

@@ -17,7 +17,7 @@
<querydsl.version>7.7</querydsl.version> <querydsl.version>7.7</querydsl.version>
<rome.version>2.1.0</rome.version> <rome.version>2.1.0</rome.version>
<!-- renovate: depName=com.google.googlejavaformat:google-java-format --> <!-- renovate: depName=com.google.googlejavaformat:google-java-format -->
<google-java-format.version>1.36.1</google-java-format.version> <google-java-format.version>2.0.0</google-java-format.version>
<build.database>h2</build.database> <build.database>h2</build.database>
</properties> </properties>
@@ -164,7 +164,7 @@
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId> <artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version> <version>3.6.1</version>
<configuration> <configuration>
<argLine>@{argLine}</argLine> <argLine>@{argLine}</argLine>
<systemPropertyVariables> <systemPropertyVariables>
@@ -176,7 +176,7 @@
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId> <artifactId>maven-failsafe-plugin</artifactId>
<version>3.6.0</version> <version>3.6.1</version>
<executions> <executions>
<execution> <execution>
<goals> <goals>
@@ -245,7 +245,7 @@
<dependency> <dependency>
<groupId>com.puppycrawl.tools</groupId> <groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId> <artifactId>checkstyle</artifactId>
<version>14.3.0</version> <version>14.4.0</version>
</dependency> </dependency>
</dependencies> </dependencies>
<executions> <executions>
@@ -404,7 +404,7 @@
<dependency> <dependency>
<groupId>dev.cel</groupId> <groupId>dev.cel</groupId>
<artifactId>cel</artifactId> <artifactId>cel</artifactId>
<version>0.14.0</version> <version>0.15.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.passay</groupId> <groupId>org.passay</groupId>
@@ -439,7 +439,7 @@
<dependency> <dependency>
<groupId>com.ibm.icu</groupId> <groupId>com.ibm.icu</groupId>
<artifactId>icu4j</artifactId> <artifactId>icu4j</artifactId>
<version>78.3</version> <version>79.1</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>net.sourceforge.cssparser</groupId> <groupId>net.sourceforge.cssparser</groupId>
@@ -482,7 +482,7 @@
<dependency> <dependency>
<groupId>org.mock-server</groupId> <groupId>org.mock-server</groupId>
<artifactId>mockserver-junit-jupiter-no-dependencies</artifactId> <artifactId>mockserver-junit-jupiter-no-dependencies</artifactId>
<version>9.1.0</version> <version>9.2.0</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency> <dependency>

View File

@@ -1,4 +1,4 @@
FROM ibm-semeru-runtimes:open-jdk-25.0.4.15-jre@sha256:630c738228597b4cd31d9823e479384729b03684bac23478b33fdbda13ae31b8 FROM ibm-semeru-runtimes:open-jdk-25.0.4.15-jre@sha256:c4f8cd5ee9d607addbf957a5066144c46c853c6a81235819f6952e60237904ca
LABEL org.opencontainers.image.source="https://github.com/Athou/commafeed" LABEL org.opencontainers.image.source="https://github.com/Athou/commafeed"
EXPOSE 8082 EXPOSE 8082

View File

@@ -1,4 +1,4 @@
FROM debian:13.7@sha256:4c25275446ca371766b8b093c696782eabce06263fccacb8d5d813b3dc44b308 FROM debian:13.7@sha256:2dc419c6d7fe3dad0a2f9fd469c34cc90b0f9ba642d8fde837fe3d7d34a0e7f1
LABEL org.opencontainers.image.source="https://github.com/Athou/commafeed" LABEL org.opencontainers.image.source="https://github.com/Athou/commafeed"
ARG TARGETARCH ARG TARGETARCH

View File

@@ -66,8 +66,7 @@ public class HttpClientFactory {
String userAgent = String userAgent =
config.httpClient() config.httpClient()
.userAgent() .userAgent()
.orElseGet( .orElseGet(() ->
() ->
String.format( String.format(
"CommaFeed/%s (https://github.com/Athou/commafeed)", "CommaFeed/%s (https://github.com/Athou/commafeed)",
version.getVersion())); version.getVersion()));

View File

@@ -283,8 +283,7 @@ public class HttpGetter {
} }
return CacheBuilder.newBuilder() return CacheBuilder.newBuilder()
.weigher( .weigher((HttpRequest key, HttpResponse value) ->
(HttpRequest key, HttpResponse value) ->
value.content() != null ? value.content().length : 0) value.content() != null ? value.content().length : 0)
.maximumWeight(cacheConfig.maximumMemorySize().asLongValue()) .maximumWeight(cacheConfig.maximumMemorySize().asLongValue())
.expireAfterWrite(cacheConfig.expiration()) .expireAfterWrite(cacheConfig.expiration())

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.FeedCategory; import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.QFeedCategory; import com.commafeed.backend.model.QFeedCategory;
import com.commafeed.backend.model.QUser; import com.commafeed.backend.model.QUser;
@@ -17,8 +18,8 @@ public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory; private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory;
public FeedCategoryDAO(EntityManager entityManager) { public FeedCategoryDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedCategory.class); super(entityManager, FeedCategory.class, config);
} }
public List<FeedCategory> findAll(User user) { public List<FeedCategory> findAll(User user) {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.QFeed; import com.commafeed.backend.model.QFeed;
import com.commafeed.backend.model.QFeedSubscription; import com.commafeed.backend.model.QFeedSubscription;
@@ -20,8 +21,8 @@ public class FeedDAO extends GenericDAO<Feed> {
private static final QFeed FEED = QFeed.feed; private static final QFeed FEED = QFeed.feed;
private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription; private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription;
public FeedDAO(EntityManager entityManager) { public FeedDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, Feed.class); super(entityManager, Feed.class, config);
} }
public List<Feed> findByIds(List<Long> id) { public List<Feed> findByIds(List<Long> id) {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.FeedEntryContent; import com.commafeed.backend.model.FeedEntryContent;
import com.commafeed.backend.model.QFeedEntry; import com.commafeed.backend.model.QFeedEntry;
import com.commafeed.backend.model.QFeedEntryContent; import com.commafeed.backend.model.QFeedEntryContent;
@@ -15,8 +16,8 @@ public class FeedEntryContentDAO extends GenericDAO<FeedEntryContent> {
private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent; private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent;
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry; private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
public FeedEntryContentDAO(EntityManager entityManager) { public FeedEntryContentDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedEntryContent.class); super(entityManager, FeedEntryContent.class, config);
} }
public List<FeedEntryContent> findExisting(String contentHash, String titleHash) { public List<FeedEntryContent> findExisting(String contentHash, String titleHash) {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedEntry; import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.QFeedEntry; import com.commafeed.backend.model.QFeedEntry;
@@ -23,8 +24,8 @@ public class FeedEntryDAO extends GenericDAO<FeedEntry> {
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry; private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
private static final int IN_CLAUSE_BATCH_SIZE = 1000; private static final int IN_CLAUSE_BATCH_SIZE = 1000;
public FeedEntryDAO(EntityManager entityManager) { public FeedEntryDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedEntry.class); super(entityManager, FeedEntry.class, config);
} }
public FeedEntry findExisting(String guidHash, Feed feed) { public FeedEntry findExisting(String guidHash, Feed feed) {

View File

@@ -29,6 +29,7 @@ import org.apache.commons.collections4.CollectionUtils;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -50,7 +51,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
EntityManager entityManager, EntityManager entityManager,
FeedEntryTagDAO feedEntryTagDAO, FeedEntryTagDAO feedEntryTagDAO,
CommaFeedConfiguration config) { CommaFeedConfiguration config) {
super(entityManager, FeedEntryStatus.class); super(entityManager, FeedEntryStatus.class, config);
this.feedEntryTagDAO = feedEntryTagDAO; this.feedEntryTagDAO = feedEntryTagDAO;
this.config = config; this.config = config;
} }
@@ -129,8 +130,6 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
query.limit(limit); query.limit(limit);
} }
setTimeout(query, config.database().queryTimeout());
List<FeedEntryStatus> statuses = query.fetch(); List<FeedEntryStatus> statuses = query.fetch();
statuses.forEach(s -> s.setMarkable(true)); statuses.forEach(s -> s.setMarkable(true));
if (includeContent) { if (includeContent) {
@@ -207,8 +206,6 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
query.limit(limit); query.limit(limit);
} }
setTimeout(query, config.database().queryTimeout());
List<FeedEntryStatus> statuses = new ArrayList<>(); List<FeedEntryStatus> statuses = new ArrayList<>();
List<Tuple> tuples = query.fetch(); List<Tuple> tuples = query.fetch();
for (Tuple tuple : tuples) { for (Tuple tuple : tuples) {
@@ -238,19 +235,41 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
} }
} }
public UnreadCount getUnreadCount(FeedSubscription sub) { public Map<Long, UnreadCount> getUnreadCounts(List<FeedSubscription> subs) {
if (subs.isEmpty()) {
return Map.of();
}
Map<Long, List<FeedSubscription>> subsByFeedId =
subs.stream().collect(Collectors.groupingBy(s -> s.getFeed().getId()));
JPAQuery<Tuple> query = JPAQuery<Tuple> query =
query().select(ENTRY.count(), ENTRY.published.max()) query().select(ENTRY.feed.id, ENTRY.count(), ENTRY.published.max())
.from(ENTRY) .from(ENTRY)
.leftJoin(ENTRY.statuses, STATUS) .leftJoin(ENTRY.statuses, STATUS)
.on(STATUS.subscription.eq(sub)) .on(STATUS.subscription.in(subs))
.where(ENTRY.feed.eq(sub.getFeed())) .where(ENTRY.feed.id.in(subsByFeedId.keySet()))
.where(buildUnreadPredicate()); .where(buildUnreadPredicate())
.groupBy(ENTRY.feed.id);
Tuple tuple = query.fetchOne(); Map<Long, UnreadCount> result = new HashMap<>();
for (Tuple tuple : query.fetch()) {
Long feedId = tuple.get(ENTRY.feed.id);
Long count = tuple.get(ENTRY.count()); Long count = tuple.get(ENTRY.count());
Instant published = tuple.get(ENTRY.published.max()); Instant published = tuple.get(ENTRY.published.max());
return new UnreadCount(sub.getId(), count == null ? 0 : count, published); for (FeedSubscription sub : subsByFeedId.get(feedId)) {
result.put(
sub.getId(),
new UnreadCount(sub.getId(), count == null ? 0 : count, published));
}
}
// feeds with no unread entries are absent from the query result, default them to zero
for (FeedSubscription sub : subs) {
result.putIfAbsent(sub.getId(), new UnreadCount(sub.getId(), 0, null));
}
return result;
} }
private BooleanBuilder buildUnreadPredicate() { private BooleanBuilder buildUnreadPredicate() {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.FeedEntry; import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryTag; import com.commafeed.backend.model.FeedEntryTag;
import com.commafeed.backend.model.QFeedEntryTag; import com.commafeed.backend.model.QFeedEntryTag;
@@ -17,8 +18,8 @@ public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> {
private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag; private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag;
public FeedEntryTagDAO(EntityManager entityManager) { public FeedEntryTagDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedEntryTag.class); super(entityManager, FeedEntryTag.class, config);
} }
public List<String> findByUser(User user) { public List<String> findByUser(User user) {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.AbstractModel; import com.commafeed.backend.model.AbstractModel;
import com.commafeed.backend.model.Feed; import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedCategory; import com.commafeed.backend.model.FeedCategory;
@@ -31,8 +32,8 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
private final EntityManager entityManager; private final EntityManager entityManager;
public FeedSubscriptionDAO(EntityManager entityManager) { public FeedSubscriptionDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, FeedSubscription.class); super(entityManager, FeedSubscription.class, config);
this.entityManager = entityManager; this.entityManager = entityManager;
} }
@@ -124,10 +125,8 @@ public class FeedSubscriptionDAO extends GenericDAO<FeedSubscription> {
Set<Long> categoryIds = Set<Long> categoryIds =
categories.stream().map(AbstractModel::getId).collect(Collectors.toSet()); categories.stream().map(AbstractModel::getId).collect(Collectors.toSet());
return findAll(user).stream() return findAll(user).stream()
.filter( .filter(s ->
s -> s.getCategory() != null && categoryIds.contains(s.getCategory().getId()))
s.getCategory() != null
&& categoryIds.contains(s.getCategory().getId()))
.toList(); .toList();
} }

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.AbstractModel; import com.commafeed.backend.model.AbstractModel;
import com.querydsl.core.types.EntityPath; import com.querydsl.core.types.EntityPath;
import com.querydsl.jpa.impl.JPADeleteClause; import com.querydsl.jpa.impl.JPADeleteClause;
@@ -21,9 +22,15 @@ public abstract class GenericDAO<T extends AbstractModel> {
private final EntityManager entityManager; private final EntityManager entityManager;
private final Class<T> entityClass; private final Class<T> entityClass;
private final CommaFeedConfiguration config;
protected JPAQueryFactory query() { protected JPAQueryFactory query() {
return new JPAQueryFactory(entityManager); return new JPAQueryFactory(entityManager) {
@Override
public JPAQuery<?> query() {
return withTimeout(super.query(), config.database().queryTimeout());
}
};
} }
protected JPAUpdateClause updateQuery(EntityPath<T> entityPath) { protected JPAUpdateClause updateQuery(EntityPath<T> entityPath) {
@@ -57,9 +64,10 @@ public abstract class GenericDAO<T extends AbstractModel> {
return objects.size(); return objects.size();
} }
protected void setTimeout(JPAQuery<?> query, Duration timeout) { private JPAQuery<?> withTimeout(JPAQuery<?> query, Duration timeout) {
if (!timeout.isZero()) { if (!timeout.isZero()) {
query.setHint(SpecHints.HINT_SPEC_QUERY_TIMEOUT, Math.toIntExact(timeout.toMillis())); query.setHint(SpecHints.HINT_SPEC_QUERY_TIMEOUT, Math.toIntExact(timeout.toMillis()));
} }
return query;
} }
} }

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.QUser; import com.commafeed.backend.model.QUser;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
@@ -11,8 +12,8 @@ public class UserDAO extends GenericDAO<User> {
private static final QUser USER = QUser.user; private static final QUser USER = QUser.user;
public UserDAO(EntityManager entityManager) { public UserDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, User.class); super(entityManager, User.class, config);
} }
public User findByName(String name) { public User findByName(String name) {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.QUserRole; import com.commafeed.backend.model.QUserRole;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserRole; import com.commafeed.backend.model.UserRole;
@@ -17,8 +18,8 @@ public class UserRoleDAO extends GenericDAO<UserRole> {
private static final QUserRole ROLE = QUserRole.userRole; private static final QUserRole ROLE = QUserRole.userRole;
public UserRoleDAO(EntityManager entityManager) { public UserRoleDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, UserRole.class); super(entityManager, UserRole.class, config);
} }
public List<UserRole> findAll() { public List<UserRole> findAll() {

View File

@@ -1,5 +1,6 @@
package com.commafeed.backend.dao; package com.commafeed.backend.dao;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.QUserSettings; import com.commafeed.backend.model.QUserSettings;
import com.commafeed.backend.model.User; import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings; import com.commafeed.backend.model.UserSettings;
@@ -12,8 +13,8 @@ public class UserSettingsDAO extends GenericDAO<UserSettings> {
private static final QUserSettings SETTINGS = QUserSettings.userSettings; private static final QUserSettings SETTINGS = QUserSettings.userSettings;
public UserSettingsDAO(EntityManager entityManager) { public UserSettingsDAO(EntityManager entityManager, CommaFeedConfiguration config) {
super(entityManager, UserSettings.class); super(entityManager, UserSettings.class, config);
} }
public UserSettings findByUser(User user) { public UserSettings findByUser(User user) {

View File

@@ -104,17 +104,14 @@ public class FeedRefreshEngine {
private void startFeedProcessingLoop() { private void startFeedProcessingLoop() {
// take a feed from the queue, process it, rince, repeat // take a feed from the queue, process it, rince, repeat
feedProcessingLoopExecutor.submit( feedProcessingLoopExecutor.submit(() -> {
() -> {
while (!feedProcessingLoopExecutor.isShutdown()) { while (!feedProcessingLoopExecutor.isShutdown()) {
try { try {
// take() is blocking until a feed is available from the queue // take() is blocking until a feed is available from the queue
Feed feed = queue.take(); Feed feed = queue.take();
// send the feed to be processed // send the feed to be processed
log.debug( log.debug("got feed {} from the queue, send it for processing", feed.getId());
"got feed {} from the queue, send it for processing",
feed.getId());
processFeedAsync(feed); processFeedAsync(feed);
// we removed a feed from the queue, try to refill it as it may now be // we removed a feed from the queue, try to refill it as it may now be
@@ -135,8 +132,7 @@ public class FeedRefreshEngine {
private void startRefillLoop() { private void startRefillLoop() {
// refill the queue at regular intervals if it's empty // refill the queue at regular intervals if it's empty
refillLoopExecutor.submit( refillLoopExecutor.submit(() -> {
() -> {
while (!refillLoopExecutor.isShutdown()) { while (!refillLoopExecutor.isShutdown()) {
try { try {
if (queue.isEmpty()) { if (queue.isEmpty()) {
@@ -184,8 +180,7 @@ public class FeedRefreshEngine {
} }
}, },
refillExecutor) refillExecutor)
.whenComplete( .whenComplete((data, ex) -> {
(data, ex) -> {
if (ex != null) { if (ex != null) {
log.error("error while refilling the queue", ex); log.error("error while refilling the queue", ex);
} }
@@ -195,49 +190,38 @@ public class FeedRefreshEngine {
private void processFeedAsync(Feed feed) { private void processFeedAsync(Feed feed) {
CompletableFuture.supplyAsync(() -> worker.update(feed), workerExecutor) CompletableFuture.supplyAsync(() -> worker.update(feed), workerExecutor)
.thenApplyAsync(r -> updater.update(r.feed(), r.entries()), databaseUpdaterExecutor) .thenApplyAsync(r -> updater.update(r.feed(), r.entries()), databaseUpdaterExecutor)
.thenCompose( .thenCompose(r -> {
r -> {
List<CompletableFuture<Void>> futures = List<CompletableFuture<Void>> futures =
r.insertedUnreadEntriesBySubscription().entrySet().stream() r.insertedUnreadEntriesBySubscription().entrySet().stream()
.map( .map(e -> {
e -> {
FeedSubscription sub = e.getKey(); FeedSubscription sub = e.getKey();
List<FeedEntry> entries = e.getValue(); List<FeedEntry> entries = e.getValue();
notifier.notifyOverWebsocket(sub, entries); notifier.notifyOverWebsocket(sub, entries);
return CompletableFuture.runAsync( return CompletableFuture.runAsync(
() -> () -> notifier.sendPushNotifications(sub, entries),
notifier
.sendPushNotifications(
sub,
entries),
notifierExecutor); notifierExecutor);
}) })
.toList(); .toList();
return CompletableFuture.allOf( return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new));
futures.toArray(CompletableFuture[]::new));
}) })
.exceptionally( .exceptionally(ex -> {
ex -> {
log.error("error while processing feed {}", feed.getUrl(), ex); log.error("error while processing feed {}", feed.getUrl(), ex);
return null; return null;
}); });
} }
private List<Feed> getNextUpdatableFeeds(int max) { private List<Feed> getNextUpdatableFeeds(int max) {
return unitOfWork.call( return unitOfWork.call(() -> {
() -> {
Instant lastLoginThreshold = Instant lastLoginThreshold =
config.feedRefresh().userInactivityPeriod().isZero() config.feedRefresh().userInactivityPeriod().isZero()
? null ? null
: Instant.now() : Instant.now().minus(config.feedRefresh().userInactivityPeriod());
.minus(config.feedRefresh().userInactivityPeriod());
List<Feed> feeds = feedDAO.findNextUpdatable(max, lastLoginThreshold); List<Feed> feeds = feedDAO.findNextUpdatable(max, lastLoginThreshold);
if (!feeds.isEmpty()) { if (!feeds.isEmpty()) {
// update disabledUntil to prevent feeds from being returned again by // update disabledUntil to prevent feeds from being returned again by
// feedDAO.findNextUpdatable() // feedDAO.findNextUpdatable()
Instant nextUpdateDate = Instant nextUpdateDate = Instant.now().plus(config.feedRefresh().interval());
Instant.now().plus(config.feedRefresh().interval());
feedDAO.setDisabledUntil( feedDAO.setDisabledUntil(
feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate); feeds.stream().map(AbstractModel::getId).toList(), nextUpdateDate);
} }
@@ -292,8 +276,7 @@ public class FeedRefreshEngine {
ThreadPoolExecutor pool = ThreadPoolExecutor pool =
new ThreadPoolExecutor( new ThreadPoolExecutor(
threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>()); threads, threads, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>());
pool.setRejectedExecutionHandler( pool.setRejectedExecutionHandler((r, e) -> {
(r, e) -> {
if (e.isShutdown()) { if (e.isShutdown()) {
return; return;
} }

View File

@@ -91,8 +91,7 @@ public class FeedRefreshUpdater {
if (locked1 && locked2) { if (locked1 && locked2) {
processed = true; processed = true;
insertedEntry = insertedEntry =
unitOfWork.call( unitOfWork.call(() -> {
() -> {
if (feedEntryService.find(feed, entry) != null) { if (feedEntryService.find(feed, entry) != null) {
// entry already exists, nothing to do // entry already exists, nothing to do
return null; return null;
@@ -101,8 +100,7 @@ public class FeedRefreshUpdater {
FeedEntry feedEntry = feedEntryService.create(feed, entry); FeedEntry feedEntry = feedEntryService.create(feed, entry);
entryInserted.mark(); entryInserted.mark();
for (FeedSubscription sub : subscriptions) { for (FeedSubscription sub : subscriptions) {
boolean unread = boolean unread = feedEntryService.applyFilter(sub, feedEntry);
feedEntryService.applyFilter(sub, feedEntry);
if (unread) { if (unread) {
subscriptionsForWhichEntryIsUnread.add(sub); subscriptionsForWhichEntryIsUnread.add(sub);
} }
@@ -147,8 +145,7 @@ public class FeedRefreshUpdater {
AddEntryResult addEntryResult = addEntry(feed, entry, subscriptions); AddEntryResult addEntryResult = addEntry(feed, entry, subscriptions);
processed &= addEntryResult.processed; processed &= addEntryResult.processed;
inserted += addEntryResult.insertedEntry != null ? 1 : 0; inserted += addEntryResult.insertedEntry != null ? 1 : 0;
addEntryResult.subscriptionsForWhichEntryIsUnread.forEach( addEntryResult.subscriptionsForWhichEntryIsUnread.forEach(sub -> {
sub -> {
if (addEntryResult.insertedEntry != null) { if (addEntryResult.insertedEntry != null) {
insertedUnreadEntriesBySubscription insertedUnreadEntriesBySubscription
.computeIfAbsent(sub, k -> new ArrayList<>()) .computeIfAbsent(sub, k -> new ArrayList<>())

View File

@@ -65,18 +65,15 @@ public class OPMLExporter {
for (FeedCategory child : for (FeedCategory child :
categories.stream() categories.stream()
.filter( .filter(c ->
c -> c.getParent() != null && c.getParent().getId().equals(cat.getId()))
c.getParent() != null
&& c.getParent().getId().equals(cat.getId()))
.toList()) { .toList()) {
outline.getChildren().add(buildCategoryOutline(child, categories, subscriptions)); outline.getChildren().add(buildCategoryOutline(child, categories, subscriptions));
} }
for (FeedSubscription sub : for (FeedSubscription sub :
subscriptions.stream() subscriptions.stream()
.filter( .filter(s ->
s ->
s.getCategory() != null s.getCategory() != null
&& s.getCategory().getId().equals(cat.getId())) && s.getCategory().getId().equals(cat.getId()))
.toList()) { .toList()) {

View File

@@ -45,8 +45,7 @@ public class FeedEntryFilteringService {
.addVar("categories", SimpleType.STRING) .addVar("categories", SimpleType.STRING)
.addVar("categoriesLower", SimpleType.STRING) .addVar("categoriesLower", SimpleType.STRING)
.build(); .build();
private static final CelRuntime CEL_RUNTIME = private static final CelRuntime CEL_RUNTIME = CelRuntimeFactory.plannerRuntimeBuilder().build();
CelRuntimeFactory.standardCelRuntimeBuilder().build();
private final ExecutorService executor = Executors.newCachedThreadPool(); private final ExecutorService executor = Executors.newCachedThreadPool();
private final CommaFeedConfiguration config; private final CommaFeedConfiguration config;

View File

@@ -148,15 +148,13 @@ public class FeedEntryService {
List<FeedEntryStatus> statusesToMark = List<FeedEntryStatus> statusesToMark =
statuses.stream() statuses.stream()
.filter(FeedEntryStatus::isMarkable) .filter(FeedEntryStatus::isMarkable)
.filter( .filter(s -> {
s -> {
Instant entryDate = s.getEntry().getPublished(); Instant entryDate = s.getEntry().getPublished();
return olderThan == null return olderThan == null
|| entryDate == null || entryDate == null
|| entryDate.isBefore(olderThan); || entryDate.isBefore(olderThan);
}) })
.filter( .filter(s -> {
s -> {
Instant insertedDate = s.getEntry().getInserted(); Instant insertedDate = s.getEntry().getInserted();
return insertedBefore == null return insertedBefore == null
|| insertedDate == null || insertedDate == null
@@ -164,8 +162,7 @@ public class FeedEntryService {
}) })
.toList(); .toList();
statusesToMark.forEach( statusesToMark.forEach(s -> {
s -> {
s.setRead(true); s.setRead(true);
feedEntryStatusDAO.merge(s); feedEntryStatusDAO.merge(s);
}); });

View File

@@ -19,7 +19,6 @@ import lombok.extern.slf4j.Slf4j;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
@Slf4j @Slf4j
@Singleton @Singleton
@@ -47,8 +46,7 @@ public class FeedSubscriptionService {
// we need to use this hook because the feed needs to have been persisted before being // we need to use this hook because the feed needs to have been persisted before being
// processed // processed
// by the feed engine // by the feed engine
feedSubscriptionDAO.onPostCommitInsert( feedSubscriptionDAO.onPostCommitInsert(sub -> {
sub -> {
Feed feed = sub.getFeed(); Feed feed = sub.getFeed();
if (feed.getDisabledUntil() == null if (feed.getDisabledUntil() == null
|| feed.getDisabledUntil().isBefore(Instant.now())) { || feed.getDisabledUntil().isBefore(Instant.now())) {
@@ -116,10 +114,7 @@ public class FeedSubscriptionService {
} }
public Map<Long, UnreadCount> getUnreadCount(User user) { public Map<Long, UnreadCount> getUnreadCount(User user) {
return feedSubscriptionDAO.findAll(user).stream() return feedEntryStatusDAO.getUnreadCounts(feedSubscriptionDAO.findAll(user));
.collect(
Collectors.toMap(
FeedSubscription::getId, feedEntryStatusDAO::getUnreadCount));
} }
@SuppressWarnings("serial") @SuppressWarnings("serial")

View File

@@ -79,13 +79,10 @@ public class DatabaseCleaningService {
} while (entriesDeleted > 0); } while (entriesDeleted > 0);
} }
deleted = deleted =
unitOfWork.call( unitOfWork.call(() ->
() ->
feedDAO.delete( feedDAO.delete(
feedDAO.findByIds( feedDAO.findByIds(
feeds.stream() feeds.stream().map(AbstractModel::getId).toList())));
.map(AbstractModel::getId)
.toList())));
total += deleted; total += deleted;
log.debug("removed {} feeds without subscriptions", total); log.debug("removed {} feeds without subscriptions", total);
} while (deleted != 0); } while (deleted != 0);
@@ -119,8 +116,7 @@ public class DatabaseCleaningService {
} }
List<FeedCapacity> feeds = List<FeedCapacity> feeds =
unitOfWork.call( unitOfWork.call(() ->
() ->
feedEntryDAO.findFeedsExceedingCapacity( feedEntryDAO.findFeedsExceedingCapacity(
maxFeedCapacity, batchSize, keepStarredEntries)); maxFeedCapacity, batchSize, keepStarredEntries));
if (feeds.isEmpty()) { if (feeds.isEmpty()) {
@@ -139,8 +135,7 @@ public class DatabaseCleaningService {
final long rem = remaining; final long rem = remaining;
deleted = deleted =
unitOfWork.call( unitOfWork.call(() ->
() ->
feedEntryDAO.deleteOldEntries( feedEntryDAO.deleteOldEntries(
feed.id(), feed.id(),
Math.min(batchSize, rem), Math.min(batchSize, rem),
@@ -166,8 +161,7 @@ public class DatabaseCleaningService {
} }
deleted = deleted =
unitOfWork.call( unitOfWork.call(() ->
() ->
feedEntryDAO.deleteEntriesOlderThan( feedEntryDAO.deleteEntriesOlderThan(
olderThan, batchSize, keepStarredEntries)); olderThan, batchSize, keepStarredEntries));
entriesDeletedMeter.mark(deleted); entriesDeletedMeter.mark(deleted);

View File

@@ -31,8 +31,7 @@ public class DemoAccountCleanupTask extends ScheduledTask {
} }
log.info("recreating demo user account"); log.info("recreating demo user account");
unitOfWork.run( unitOfWork.run(() -> {
() -> {
User demoUser = userDAO.findByName(CommaFeedConstants.USERNAME_DEMO); User demoUser = userDAO.findByName(CommaFeedConstants.USERNAME_DEMO);
if (demoUser == null) { if (demoUser == null) {
return; return;

View File

@@ -435,6 +435,19 @@ public class CategoryREST {
&& !Strings.CS.equals(req.getParentId(), String.valueOf(req.getId()))) { && !Strings.CS.equals(req.getParentId(), String.valueOf(req.getId()))) {
parent = feedCategoryDAO.findById(user, Long.valueOf(req.getParentId())); parent = feedCategoryDAO.findById(user, Long.valueOf(req.getParentId()));
} }
if (parent != null) {
Long parentId = parent.getId();
List<FeedCategory> descendants =
feedCategoryDAO.findAllChildrenCategories(user, category);
boolean parentIsDescendant =
descendants.stream().anyMatch(c -> c.getId().equals(parentId));
if (parentIsDescendant) {
return Response.status(Status.BAD_REQUEST)
.entity(
"<message>cannot set a category's own descendant as its parent</message>")
.build();
}
}
category.setParent(parent); category.setParent(parent);
if (req.getPosition() != null) { if (req.getPosition() != null) {

View File

@@ -140,14 +140,9 @@ public class FeverREST {
Map<String, String> params = new HashMap<>(); Map<String, String> params = new HashMap<>();
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.getFirst())); uri.getQueryParameters().forEach((k, v) -> params.put(k, v.getFirst()));
form.getValues() form.getValues()
.forEach( .forEach((k, v) ->
(k, v) ->
params.put( params.put(
k, k, v.stream().map(FormValue::getValue).findFirst().orElse(null)));
v.stream()
.map(FormValue::getValue)
.findFirst()
.orElse(null)));
return handle(userId, params); return handle(userId, params);
} }
@@ -246,12 +241,10 @@ public class FeverREST {
s -> s.getCategory() == null ? 0 : s.getCategory().getId())) s -> s.getCategory() == null ? 0 : s.getCategory().getId()))
.entrySet() .entrySet()
.stream() .stream()
.map( .map(e -> {
e -> {
FeverFeedGroup fg = new FeverFeedGroup(); FeverFeedGroup fg = new FeverFeedGroup();
fg.setGroupId(e.getKey()); fg.setGroupId(e.getKey());
fg.setFeedIds( fg.setFeedIds(e.getValue().stream().map(FeedSubscription::getId).toList());
e.getValue().stream().map(FeedSubscription::getId).toList());
return fg; return fg;
}) })
.toList(); .toList();
@@ -259,8 +252,7 @@ public class FeverREST {
private List<FeverGroup> buildGroups(List<FeedCategory> categories) { private List<FeverGroup> buildGroups(List<FeedCategory> categories) {
return categories.stream() return categories.stream()
.map( .map(c -> {
c -> {
FeverGroup g = new FeverGroup(); FeverGroup g = new FeverGroup();
g.setId(c.getId()); g.setId(c.getId());
g.setTitle(c.getName()); g.setTitle(c.getName());
@@ -271,8 +263,7 @@ public class FeverREST {
private List<FeverFeed> buildFeeds(List<FeedSubscription> subscriptions) { private List<FeverFeed> buildFeeds(List<FeedSubscription> subscriptions) {
return subscriptions.stream() return subscriptions.stream()
.map( .map(s -> {
s -> {
FeverFeed f = new FeverFeed(); FeverFeed f = new FeverFeed();
f.setId(s.getId()); f.setId(s.getId());
f.setFaviconId(s.getId()); f.setFaviconId(s.getId());
@@ -367,8 +358,7 @@ public class FeverREST {
private List<FeverFavicon> buildFavicons(List<FeedSubscription> subscriptions) { private List<FeverFavicon> buildFavicons(List<FeedSubscription> subscriptions) {
return subscriptions.stream() return subscriptions.stream()
.map( .map(s -> {
s -> {
Favicon favicon = feedFaviconService.fetchFavicon(s.getFeed()); Favicon favicon = feedFaviconService.fetchFavicon(s.getFeed());
FeverFavicon f = new FeverFavicon(); FeverFavicon f = new FeverFavicon();

View File

@@ -216,8 +216,7 @@ public class GoogleReaderREST {
List<GoogleReaderModel.Tag> tags = new ArrayList<>(); List<GoogleReaderModel.Tag> tags = new ArrayList<>();
feedCategoryDAO feedCategoryDAO
.findAll(user) .findAll(user)
.forEach( .forEach(c ->
c ->
tags.add( tags.add(
new GoogleReaderModel.Tag( new GoogleReaderModel.Tag(
GoogleReaderStreamId.label(c.getName())))); GoogleReaderStreamId.label(c.getName()))));
@@ -259,8 +258,7 @@ public class GoogleReaderREST {
Map<Long, FeedCategory> categoriesById = Map<Long, FeedCategory> categoriesById =
feedCategoryDAO.findAll(user).stream() feedCategoryDAO.findAll(user).stream()
.collect(Collectors.toMap(FeedCategory::getId, c -> c)); .collect(Collectors.toMap(FeedCategory::getId, c -> c));
unreadByCategory.forEach( unreadByCategory.forEach((categoryId, count) -> {
(categoryId, count) -> {
if (categoryId == 0) { if (categoryId == 0) {
return; return;
} }
@@ -268,8 +266,7 @@ public class GoogleReaderREST {
if (category == null) { if (category == null) {
return; return;
} }
GoogleReaderModel.UnreadCountEntry entry = GoogleReaderModel.UnreadCountEntry entry = new GoogleReaderModel.UnreadCountEntry();
new GoogleReaderModel.UnreadCountEntry();
entry.setId(GoogleReaderStreamId.label(category.getName())); entry.setId(GoogleReaderStreamId.label(category.getName()));
entry.setCount(count); entry.setCount(count);
entries.add(entry); entries.add(entry);
@@ -334,10 +331,8 @@ public class GoogleReaderREST {
GoogleReaderModel.ItemRefs resp = new GoogleReaderModel.ItemRefs(); GoogleReaderModel.ItemRefs resp = new GoogleReaderModel.ItemRefs();
resp.setItemRefs( resp.setItemRefs(
paged.statuses().stream() paged.statuses().stream()
.map( .map(s ->
s -> new GoogleReaderModel.ItemRef(String.valueOf(s.getEntry().getId())))
new GoogleReaderModel.ItemRef(
String.valueOf(s.getEntry().getId())))
.toList()); .toList());
resp.setContinuation(paged.continuation()); resp.setContinuation(paged.continuation());
return resp; return resp;

View File

@@ -38,13 +38,11 @@ public class DatabaseApiKeyIdentityProvider
@Override @Override
public Uni<SecurityIdentity> authenticate( public Uni<SecurityIdentity> authenticate(
TokenAuthenticationRequest request, AuthenticationRequestContext context) { TokenAuthenticationRequest request, AuthenticationRequestContext context) {
return context.runBlocking( return context.runBlocking(() -> {
() -> {
Optional<User> user = Optional<User> user =
unitOfWork.call(() -> userService.login(request.getToken().getToken())); unitOfWork.call(() -> userService.login(request.getToken().getToken()));
if (user.isEmpty()) { if (user.isEmpty()) {
throw new AuthenticationFailedException( throw new AuthenticationFailedException("could not find a user with this api key");
"could not find a user with this api key");
} }
Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user.get())); Set<Role> roles = unitOfWork.call(() -> userService.getRoles(user.get()));

View File

@@ -39,15 +39,12 @@ public class DatabaseUsernamePasswordIdentityProvider
@Override @Override
public Uni<SecurityIdentity> authenticate( public Uni<SecurityIdentity> authenticate(
UsernamePasswordAuthenticationRequest request, AuthenticationRequestContext context) { UsernamePasswordAuthenticationRequest request, AuthenticationRequestContext context) {
return context.runBlocking( return context.runBlocking(() -> {
() -> {
Optional<User> user = Optional<User> user =
unitOfWork.call( unitOfWork.call(() ->
() ->
userService.login( userService.login(
request.getUsername(), request.getUsername(),
new String( new String(request.getPassword().getPassword())));
request.getPassword().getPassword())));
if (user.isEmpty()) { if (user.isEmpty()) {
throw new CommaFeedApplicationException( throw new CommaFeedApplicationException(
CommaFeedExceptionType.WRONG_USERNAME_OR_PASSWORD); CommaFeedExceptionType.WRONG_USERNAME_OR_PASSWORD);

View File

@@ -40,8 +40,7 @@ public class TrustedIdentityProvider implements IdentityProvider<TrustedAuthenti
@Override @Override
public Uni<SecurityIdentity> authenticate( public Uni<SecurityIdentity> authenticate(
TrustedAuthenticationRequest request, AuthenticationRequestContext context) { TrustedAuthenticationRequest request, AuthenticationRequestContext context) {
return context.runBlocking( return context.runBlocking(() -> {
() -> {
Long userId = Long.valueOf(request.getPrincipal()); Long userId = Long.valueOf(request.getPrincipal());
User user = unitOfWork.call(() -> userDAO.findById(userId)); User user = unitOfWork.call(() -> userDAO.findById(userId));
if (user == null) { if (user == null) {

View File

@@ -41,8 +41,7 @@ class NativeImageClassesTest {
WireFeedGenerator.class)) { WireFeedGenerator.class)) {
Set<Class<?>> moduleClasses = Set<Class<?>> moduleClasses =
new HashSet<>(reflections.get(Scanners.SubTypes.of(clazz).asClass())); new HashSet<>(reflections.get(Scanners.SubTypes.of(clazz).asClass()));
moduleClasses.removeIf( moduleClasses.removeIf(c ->
c ->
c.isInterface() c.isInterface()
|| Modifier.isAbstract(c.getModifiers()) || Modifier.isAbstract(c.getModifiers())
|| !Modifier.isPublic(c.getModifiers())); || !Modifier.isPublic(c.getModifiers()));

View File

@@ -317,8 +317,7 @@ class HttpGetterTest {
this.mockServerClient this.mockServerClient
.when(HttpRequest.request().withMethod("GET")) .when(HttpRequest.request().withMethod("GET"))
.respond( .respond(req -> {
req -> {
calls.incrementAndGet(); calls.incrementAndGet();
if (req.containsHeader(HttpHeaders.COOKIE)) { if (req.containsHeader(HttpHeaders.COOKIE)) {
@@ -341,8 +340,7 @@ class HttpGetterTest {
this.mockServerClient this.mockServerClient
.when(HttpRequest.request().withMethod("GET")) .when(HttpRequest.request().withMethod("GET"))
.respond( .respond(req -> {
req -> {
calls.incrementAndGet(); calls.incrementAndGet();
return HttpResponse.response().withBody("ok"); return HttpResponse.response().withBody("ok");
}); });
@@ -400,13 +398,11 @@ class HttpGetterTest {
this.mockServerClient this.mockServerClient
.when(HttpRequest.request().withMethod("GET")) .when(HttpRequest.request().withMethod("GET"))
.respond( .respond(req -> {
req -> {
calls.incrementAndGet(); calls.incrementAndGet();
if (req.containsHeader(HttpHeaders.UPGRADE)) { if (req.containsHeader(HttpHeaders.UPGRADE)) {
throw new Exception( throw new Exception("upgrade header should not be sent by the client");
"upgrade header should not be sent by the client");
} }
return HttpResponse.response().withBody("ok"); return HttpResponse.response().withBody("ok");
@@ -438,17 +434,14 @@ class HttpGetterTest {
HttpGetterTest.this HttpGetterTest.this
.mockServerClient .mockServerClient
.when(HttpRequest.request().withMethod("GET")) .when(HttpRequest.request().withMethod("GET"))
.respond( .respond(req -> {
req -> {
try { try {
String acceptEncodingHeader = String acceptEncodingHeader =
req.getFirstHeader(HttpHeaders.ACCEPT_ENCODING); req.getFirstHeader(HttpHeaders.ACCEPT_ENCODING);
if (!ACCEPT_ENCODING.equals(acceptEncodingHeader)) { if (!ACCEPT_ENCODING.equals(acceptEncodingHeader)) {
throw new Exception( throw new Exception(
"Wrong value in the Accept-Encoding header, should be '%s' but was '%s'" "Wrong value in the Accept-Encoding header, should be '%s' but was '%s'"
.formatted( .formatted(ACCEPT_ENCODING, acceptEncodingHeader));
ACCEPT_ENCODING,
acceptEncodingHeader));
} }
ByteArrayOutputStream output = new ByteArrayOutputStream(); ByteArrayOutputStream output = new ByteArrayOutputStream();

View File

@@ -68,7 +68,8 @@ class YoutubeFaviconFetcherTest {
byte[] apiResponse = byte[] apiResponse =
""" """
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}""" {"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}\
"""
.getBytes(); .getBytes();
HttpResult apiHttpResult = HttpResult apiHttpResult =
new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO); new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO);
@@ -102,7 +103,8 @@ class YoutubeFaviconFetcherTest {
byte[] apiResponse = byte[] apiResponse =
""" """
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}""" {"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}\
"""
.getBytes(); .getBytes();
HttpResult apiHttpResult = HttpResult apiHttpResult =
new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO); new HttpResult(apiResponse, "application/json", null, null, null, Duration.ZERO);
@@ -136,7 +138,8 @@ class YoutubeFaviconFetcherTest {
byte[] playlistResponse = byte[] playlistResponse =
""" """
{"items":[{"snippet":{"channelId":"testChannelId"}}]}""" {"items":[{"snippet":{"channelId":"testChannelId"}}]}\
"""
.getBytes(); .getBytes();
HttpResult playlistHttpResult = HttpResult playlistHttpResult =
new HttpResult( new HttpResult(
@@ -151,7 +154,8 @@ class YoutubeFaviconFetcherTest {
byte[] channelResponse = byte[] channelResponse =
""" """
{"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}""" {"items":[{"snippet":{"thumbnails":{"default":{"url":"https://example.com/icon.png"}}}}]}\
"""
.getBytes(); .getBytes();
HttpResult channelHttpResult = HttpResult channelHttpResult =
new HttpResult( new HttpResult(

View File

@@ -229,11 +229,13 @@ class XMLCleanerTest {
<!DOCTYPE <!DOCTYPE
html html
> >
<html><head></head><body></body></html>"""; <html><head></head><body></body></html>\
""";
Assertions.assertEquals( Assertions.assertEquals(
""" """
<html><head></head><body></body></html>""", <html><head></head><body></body></html>\
""",
xmlCleaner.removeDoctypeDeclarations(source)); xmlCleaner.removeDoctypeDeclarations(source));
} }

View File

@@ -21,7 +21,8 @@ class InPageReferenceFeedURLProviderTest {
</head> </head>
<body> <body>
</body> </body>
</html>"""; </html>\
""";
Assertions.assertIterableEquals( Assertions.assertIterableEquals(
List.of("http://example.com/feed.atom", "http://example.com/feed.rss"), List.of("http://example.com/feed.atom", "http://example.com/feed.rss"),
@@ -35,7 +36,8 @@ class InPageReferenceFeedURLProviderTest {
""" """
<?xml version="1.0"?> <?xml version="1.0"?>
<feed></feed> <feed></feed>
</xml>"""; </xml>\
""";
Assertions.assertTrue(provider.get(url, html).isEmpty()); Assertions.assertTrue(provider.get(url, html).isEmpty());
} }
@@ -51,7 +53,8 @@ class InPageReferenceFeedURLProviderTest {
</head> </head>
<body> <body>
</body> </body>
</html>"""; </html>\
""";
Assertions.assertTrue(provider.get(url, html).isEmpty()); Assertions.assertTrue(provider.get(url, html).isEmpty());
} }

View File

@@ -170,8 +170,7 @@ class GoogleReaderIT extends BaseIT {
client.get(BASE + "/reader/api/0/unread-count").as(UnreadCounts.class); client.get(BASE + "/reader/api/0/unread-count").as(UnreadCounts.class);
Assertions.assertTrue( Assertions.assertTrue(
counts.getUnreadCounts().stream() counts.getUnreadCounts().stream()
.anyMatch( .anyMatch(c ->
c ->
c.getId().equals("user/-/state/com.google/reading-list") c.getId().equals("user/-/state/com.google/reading-list")
&& c.getCount() == 2)); && c.getCount() == 2));
} }