add a setting to mark entries of a feed as read after a number of days (#2041)

This commit is contained in:
Athou
2026-02-20 10:45:17 +01:00
parent f87d3359c2
commit 3fd5cfdecd
39 changed files with 507 additions and 1 deletions

View File

@@ -18,15 +18,19 @@ import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedEntryTag;
import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.QFeed;
import com.commafeed.backend.model.QFeedEntry;
import com.commafeed.backend.model.QFeedEntryContent;
import com.commafeed.backend.model.QFeedEntryStatus;
import com.commafeed.backend.model.QFeedEntryTag;
import com.commafeed.backend.model.QFeedSubscription;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.frontend.model.UnreadCount;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.jpa.impl.JPAQuery;
@Singleton
@@ -34,8 +38,10 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
private static final QFeedEntryStatus STATUS = QFeedEntryStatus.feedEntryStatus;
private static final QFeedEntry ENTRY = QFeedEntry.feedEntry;
private static final QFeed FEED = QFeed.feed;
private static final QFeedEntryContent CONTENT = QFeedEntryContent.feedEntryContent;
private static final QFeedEntryTag TAG = QFeedEntryTag.feedEntryTag;
private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription;
private final FeedEntryTagDAO feedEntryTagDAO;
private final CommaFeedConfiguration config;
@@ -232,4 +238,58 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
return deleteQuery(STATUS).where(STATUS.id.in(ids)).execute();
}
public long autoMarkAsRead(int limit) {
Instant now = Instant.now();
BooleanBuilder where = new BooleanBuilder();
where.and(SUBSCRIPTION.autoMarkAsReadAfterDays.isNotNull());
where.and(SUBSCRIPTION.autoMarkAsReadAfterDays.gt(0));
NumberExpression<Integer> daysDiff = Expressions.numberTemplate(Integer.class, "TIMESTAMPDIFF(DAY, {0}, {1})", ENTRY.published,
now);
where.and(daysDiff.goe(SUBSCRIPTION.autoMarkAsReadAfterDays));
where.and(buildUnreadPredicate());
List<Tuple> tuples = query().select(ENTRY, STATUS, SUBSCRIPTION)
.from(ENTRY)
.join(ENTRY.feed, FEED)
.join(SUBSCRIPTION)
.on(SUBSCRIPTION.feed.eq(FEED))
.leftJoin(ENTRY.statuses, STATUS)
.on(STATUS.subscription.eq(SUBSCRIPTION))
.where(where)
.limit(limit)
.fetch();
long updated = 0;
// Update existing statuses
List<Long> statusIdsToUpdate = tuples.stream()
.map(t -> t.get(STATUS))
.filter(s -> s != null && s.getId() != null)
.map(FeedEntryStatus::getId)
.distinct()
.toList();
if (!statusIdsToUpdate.isEmpty()) {
updated += updateQuery(STATUS).where(STATUS.id.in(statusIdsToUpdate)).set(STATUS.read, true).execute();
}
// Insert new statuses for entries without existing status
for (Tuple tuple : tuples) {
FeedEntryStatus status = tuple.get(STATUS);
if (status == null || status.getId() == null) {
FeedEntry entry = tuple.get(ENTRY);
FeedSubscription sub = tuple.get(SUBSCRIPTION);
FeedEntryStatus newStatus = new FeedEntryStatus(sub.getUser(), sub, entry);
newStatus.setRead(true);
persist(newStatus);
updated++;
}
}
return updated;
}
}

View File

@@ -49,4 +49,7 @@ public class FeedSubscription extends AbstractModel {
@Column(name = "push_notifications_enabled")
private boolean pushNotificationsEnabled;
@Column(name = "auto_mark_as_read_after_days")
private Integer autoMarkAsReadAfterDays;
}

View File

@@ -133,4 +133,16 @@ public class DatabaseCleaningService {
} while (deleted != 0);
log.info("cleanup done: {} old read statuses deleted", total);
}
public void autoMarkAsRead() {
log.info("marking entries as read based on autoMarkAsReadAfterDays");
long total = 0;
long marked;
do {
marked = unitOfWork.call(() -> feedEntryStatusDAO.autoMarkAsRead(batchSize));
total += marked;
log.debug("marked {} entries as read", total);
} while (marked != 0);
log.info("cleanup done: marked {} entries as read", total);
}
}

View File

@@ -0,0 +1,37 @@
package com.commafeed.backend.task;
import java.util.concurrent.TimeUnit;
import jakarta.inject.Singleton;
import com.commafeed.backend.service.db.DatabaseCleaningService;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Singleton
public class AutoMarkAsReadTask extends ScheduledTask {
private final DatabaseCleaningService cleaner;
@Override
public void run() {
cleaner.autoMarkAsRead();
}
@Override
public long getInitialDelay() {
return 25;
}
@Override
public long getPeriod() {
return 60;
}
@Override
public TimeUnit getTimeUnit() {
return TimeUnit.MINUTES;
}
}

View File

@@ -68,6 +68,9 @@ public class Subscription implements Serializable {
@Schema(description = "whether to send push notifications for new entries of this feed", required = true)
private boolean pushNotificationsEnabled;
@Schema(description = "automatically mark entries as read after this many days (null to disable)")
private Integer autoMarkAsReadAfterDays;
public static Subscription build(FeedSubscription subscription, UnreadCount unreadCount) {
FeedCategory category = subscription.getCategory();
Feed feed = subscription.getFeed();
@@ -89,6 +92,7 @@ public class Subscription implements Serializable {
sub.setFilter(subscription.getFilter());
sub.setFilterLegacy(subscription.getFilterLegacy());
sub.setPushNotificationsEnabled(subscription.isPushNotificationsEnabled());
sub.setAutoMarkAsReadAfterDays(subscription.getAutoMarkAsReadAfterDays());
return sub;
}

View File

@@ -34,4 +34,7 @@ public class FeedModificationRequest implements Serializable {
@Schema(description = "whether to send push notifications for new entries of this feed")
private boolean pushNotificationsEnabled;
@Schema(description = "automatically mark entries as read after this many days (null to disable)")
private Integer autoMarkAsReadAfterDays;
}

View File

@@ -437,6 +437,7 @@ public class FeedREST {
}
subscription.setPushNotificationsEnabled(req.isPushNotificationsEnabled());
subscription.setAutoMarkAsReadAfterDays(req.getAutoMarkAsReadAfterDays());
if (StringUtils.isNotBlank(req.getName())) {
subscription.setTitle(req.getName());

View File

@@ -32,5 +32,15 @@
</addColumn>
</changeSet>
</databaseChangeLog>
<changeSet id="add-auto-mark-as-read-after-days" author="athou">
<addColumn tableName="FEEDSUBSCRIPTIONS">
<column name="auto_mark_as_read_after_days" type="INT">
<constraints nullable="true" />
</column>
</addColumn>
<createIndex indexName="feedsubscriptions_automark_index" tableName="FEEDSUBSCRIPTIONS">
<column name="auto_mark_as_read_after_days" />
</createIndex>
</changeSet>
</databaseChangeLog>

View File

@@ -15,6 +15,7 @@ import com.commafeed.TestConstants;
import com.commafeed.backend.service.db.DatabaseCleaningService;
import com.commafeed.frontend.model.Entries;
import com.commafeed.frontend.model.Entry;
import com.commafeed.frontend.model.request.FeedModificationRequest;
import com.commafeed.frontend.model.request.StarRequest;
import com.commafeed.frontend.resource.CategoryREST;
import com.commafeed.integration.BaseIT;
@@ -182,4 +183,30 @@ class DatabaseCleaningIT extends BaseIT {
Assertions.assertEquals(0, entriesAfter.getEntries().size());
}
}
@Nested
class AutoMarkAsRead {
@Test
void entriesAreMarkedAsReadAfterSpecifiedDays() {
// Subscribe to feed
Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl());
// verify we have 2 unread entries
Entries entries = getFeedEntries(subscriptionId);
Assertions.assertEquals(2, entries.getEntries().stream().filter(e -> !e.isRead()).count());
// set auto-mark as read
FeedModificationRequest req = new FeedModificationRequest();
req.setId(subscriptionId);
req.setAutoMarkAsReadAfterDays(1);
RestAssured.given().body(req).contentType(ContentType.JSON).post("rest/feed/modify").then().statusCode(200);
// run auto-mark as read
databaseCleaningService.autoMarkAsRead();
// verify all entries are now read
entries = getFeedEntries(subscriptionId);
Assertions.assertEquals(0, entries.getEntries().stream().filter(e -> !e.isRead()).count());
}
}
}