Add read status hook for entries (#8995)

- Add a new `EntriesRead` / `entries_read` hook for extensions.
- Trigger the hook when `markRead()` changes entry read/unread state for explicit entry IDs.
- Cover both the default DAO path and the SQLite/PGSQL DAO path.
- Add Minz hook tests for the new hook signature and argument passing.

## Why

Extensions can already react to favorite/bookmark changes through `EntriesFavorite`. This adds the matching read/unread hook requested in #4051 for extensions that need to collect stats or react to manual read state changes.

Closes #4051
This commit is contained in:
Jam Balaya
2026-07-07 16:04:32 +09:00
committed by GitHub
parent cb13091b37
commit 310bcb5e90
5 changed files with 43 additions and 1 deletions

View File

@@ -538,6 +538,9 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
if (($affected > 0) && (!$this->updateCacheUnreads(null, null))) {
return false;
}
if ($affected > 0) {
Minz_ExtensionManager::callHook(Minz_HookType::EntriesRead, $ids, $is_read);
}
return $affected;
} else {
FreshRSS_UserDAO::touch();
@@ -555,7 +558,11 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
$stm->bindValue(':id', $ids, PDO::PARAM_STR) && // TODO: Test PDO::PARAM_INT on 32-bit platform
$stm->bindValue(':old_is_read', $is_read ? 0 : 1, PDO::PARAM_INT) &&
$stm->execute()) {
return $stm->rowCount();
$affected = $stm->rowCount();
if ($affected > 0) {
Minz_ExtensionManager::callHook(Minz_HookType::EntriesRead, [$ids], $is_read);
}
return $affected;
} else {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
/** @var array{0:string,1:int,2:string} $info */

View File

@@ -164,6 +164,9 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
}
}
$this->pdo->commit();
if ($affected > 0) {
Minz_ExtensionManager::callHook(Minz_HookType::EntriesRead, [$ids], $is_read);
}
return $affected;
}
}

View File

@@ -181,6 +181,8 @@ Example response for a `query_icon_info` request:
* `Minz_HookType::CustomFaviconHash` (`function(FreshRSS_Feed $feed): string | null`): Enables the modification of custom favicon hashes by returning params from the hook function. The hook should check if the `customFaviconExt` attribute of `$feed` is set to the extension's name before returning a custom value. Otherwise, the return value should be null.
* `Minz_HookType::EntriesFavorite` (`function(array $ids, bool $is_favorite): void`):
will be executed when some entries are marked or unmarked as favorites (starred)
* `Minz_HookType::EntriesRead` (`function(array $ids, bool $is_read): void`):
will be executed when some entries are marked or unmarked as read
* `Minz_HookType::EntryAutoRead` (`function(FreshRSS_Entry $entry, string $why): void`): Triggered when an entry is automatically marked as read. The *why* parameter supports the rules {`filter`, `upon_reception`, `same_title_in_feed`, `same_guid_in_category`}.
* `Minz_HookType::EntryAutoUnread` (`function(FreshRSS_Entry $entry, string $why): void`): Triggered when an entry is automatically marked as unread. The *why* parameter supports the rules {`updated_article`}.
* `Minz_HookType::EntryBeforeDisplay` (`function($entry) -> Entry | null`): will be executed every time an entry is rendered. The entry itself (instance of FreshRSS\_Entry) will be passed as parameter.

View File

@@ -9,6 +9,7 @@ enum Minz_HookType: string {
case CustomFaviconBtnUrl = 'custom_favicon_btn_url'; // function(FreshRSS_Feed $feed): string | null
case CustomFaviconHash = 'custom_favicon_hash'; // function(FreshRSS_Feed $feed): string | null
case EntriesFavorite = 'entries_favorite'; // function(array $ids, bool $is_favorite): void
case EntriesRead = 'entries_read'; // function(array $ids, bool $is_read): void
case EntryAutoRead = 'entry_auto_read'; // function(FreshRSS_Entry $entry, string $why): void
case EntryAutoUnread = 'entry_auto_unread'; // function(FreshRSS_Entry $entry, string $why): void
case EntryBeforeAdd = 'entry_before_add'; // function(FreshRSS_Entry $entry) -> FreshRSS_Entry | null
@@ -62,6 +63,7 @@ enum Minz_HookType: string {
case self::CustomFaviconBtnUrl:
case self::CustomFaviconHash:
case self::EntriesFavorite:
case self::EntriesRead:
case self::EntryAutoRead:
case self::EntryAutoUnread:
case self::SimplepieAfterInit:

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
class HookTypeTest extends TestCase
{
public static function testEntriesReadHookUsesPassArgumentsSignature(): void {
self::assertSame(Minz_HookSignature::PassArguments, Minz_HookType::EntriesRead->signature());
}
public static function testEntriesReadHookPassesIdsAndReadState(): void {
$actual_ids = [];
$actual_is_read = null;
Minz_ExtensionManager::addHook(
Minz_HookType::EntriesRead,
static function (array $ids, bool $is_read) use (&$actual_ids, &$actual_is_read): void {
$actual_ids = $ids;
$actual_is_read = $is_read;
}
);
Minz_ExtensionManager::callHook(Minz_HookType::EntriesRead, ['123', '456'], false);
self::assertSame(['123', '456'], $actual_ids);
self::assertFalse($actual_is_read);
}
}