Files
FreshRSS/app/Models/FeedDAO.php
Jam Balaya f201061345 Feeds tree: sort feed names with locale-aware collation (#8985)
## Problem

The feed **tree** (left sidebar) and the **feed-title view** (main body) order feeds differently for names with accents or symbols. As reported in #8300, a Spanish feed starting with `Ú` appears **last** in the tree, after every ASCII-named feed.

The tree is sorted in PHP by `FreshRSS_Category::sortFeeds()` with `strnatcasecmp()`, which compares raw UTF-8 **bytes** — so any name starting with a non-ASCII character (`0xC3…` for `Á`, `Ñ`, `Ú`, …) sorts after `A–Z`. The main view is sorted by the database according to its collation, which orders accented letters near their base letter — hence the mismatch (as @Alkarex noted in the issue).

Closes #8300.

## Change

`sortFeeds()` now compares with a `Collator` (`ext-intl`, already a hard requirement) for the user's language, falling back to the previous `strnatcasecmp()` if a collator cannot be created:

```php
$collator = \Collator::create(FreshRSS_Context::hasUserConf() ? FreshRSS_Context::userConf()->language : '');
```

Sorting a Spanish set, before / after:

```
old: banana, Manzana, nube, Zorro, Ábaco, Ñandú, Úlcera, árbol
new: Ábaco, árbol, banana, Manzana, nube, Ñandú, Úlcera, Zorro
```

This makes the tree order locale-aware and much closer to the main view. Exact parity with the main view still depends on the database collation (which is admin/DB-specific and cannot be reproduced generically in PHP), but the "accented names sort last" problem is gone.

* Feeds tree: sort feed names with locale-aware collation

The feed tree sorted names byte-wise with strnatcasecmp(), which places
names starting with a non-ASCII character (e.g. an accented capital such
as "U-acute") after every ASCII name. Use a Collator for the user's
language so accented and non-ASCII names sort near their base letter,
closer to the database collation used on the main feed-title view.

For https://github.com/FreshRSS/FreshRSS/issues/8300

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Guard Collator with class_exists() so intl stays optional

The intl extension is only a recommended (not required) dependency, but
sortFeeds() called \Collator::create() unconditionally when a user language
was set. On installs without intl the \Collator class does not exist, so the
call raised a fatal "Class \"Collator\" not found" — effectively promoting
intl from recommended to required.

Guard the call with class_exists('Collator'): when intl is absent, fall back
to the previous byte-wise strnatcasecmp() sort. Locale-aware ordering stays a
progressive enhancement and the listed requirements are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Shorten the sortFeeds() collation comment per review

Trim the rationale comment to a concise four lines as suggested in review,
keeping the locale-aware/fallback and class_exists() reasoning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Stylistic preference

* Factorise localeCompare

* Minor code preference

* Preserve natural locale sort behaviour

* Avoid repeated locale lookup in comparator

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
2026-07-07 15:42:12 +02:00

770 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
class FreshRSS_FeedDAO extends Minz_ModelPdo {
public function sqlResetSequence(): bool {
return true; // Nothing to do for MySQL
}
protected function addColumn(string $name): bool {
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
Minz_Log::warning(__METHOD__ . ': ' . $name);
try {
if ($name === 'kind') { //v1.20.0
return $this->pdo->exec('ALTER TABLE `_feed` ADD COLUMN kind SMALLINT DEFAULT 0') !== false;
}
} catch (Exception $e) {
Minz_Log::error(__METHOD__ . ' error: ' . $e->getMessage());
}
return false;
}
/** @param array{0:string,1:int,2:string} $errorInfo */
public function autoUpdateDb(array $errorInfo): bool {
if (isset($errorInfo[0])) {
if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
$errorLines = explode("\n", $errorInfo[2], 2); // The relevant column name is on the first line, other lines are noise
foreach (['kind'] as $column) {
if (str_contains($errorLines[0], $column)) {
return $this->addColumn($column);
}
}
}
}
return false;
}
/**
* @param array{id?:int,url:string,kind:int,category:int,name:string,website:string,description:string,lastUpdate:int,priority?:int,
* pathEntries?:string,httpAuth?:string,error:int|bool,ttl?:int,attributes?:string|array<string|mixed>} $valuesTmp
*/
public function addFeed(array $valuesTmp): int|false {
if (empty($valuesTmp['id'])) { // Auto-generated ID
$sql = <<<'SQL'
INSERT INTO `_feed` (url, kind, category, name, website, description, `lastUpdate`, priority, `pathEntries`, `httpAuth`, error, ttl, attributes)
VALUES (:url, :kind, :category, :name, :website, :description, :last_update, :priority, :path_entries, :http_auth, :error, :ttl, :attributes)
SQL;
} else {
$sql = <<<'SQL'
INSERT INTO `_feed` (id, url, kind, category, name, website, description, `lastUpdate`, priority, `pathEntries`, `httpAuth`, error, ttl, attributes)
VALUES (:id, :url, :kind, :category, :name, :website, :description, :last_update, :priority, :path_entries, :http_auth, :error, :ttl, :attributes)
SQL;
}
$stm = $this->pdo->prepare($sql);
if (!isset($valuesTmp['pathEntries'])) {
$valuesTmp['pathEntries'] = '';
}
if (!isset($valuesTmp['attributes'])) {
$valuesTmp['attributes'] = [];
}
$ok = $stm !== false;
if ($ok) {
if (!empty($valuesTmp['id'])) {
$ok &= $stm->bindValue(':id', (int)$valuesTmp['id'], PDO::PARAM_INT);
}
$ok &= $stm->bindValue(':url', safe_ascii($valuesTmp['url']), PDO::PARAM_STR);
$ok &= $stm->bindValue(':kind', $valuesTmp['kind'] ?? FreshRSS_Feed::KIND_RSS, PDO::PARAM_INT);
$ok &= $stm->bindValue(':category', $valuesTmp['category'], PDO::PARAM_INT);
$ok &= $stm->bindValue(':name', mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8'), PDO::PARAM_STR);
$ok &= $stm->bindValue(':website', safe_ascii($valuesTmp['website']), PDO::PARAM_STR);
$ok &= $stm->bindValue(':description', FreshRSS_SimplePieCustom::sanitizeHTML($valuesTmp['description'], ''), PDO::PARAM_STR);
$ok &= $stm->bindValue(':last_update', $valuesTmp['lastUpdate'], PDO::PARAM_INT);
$ok &= $stm->bindValue(':priority', isset($valuesTmp['priority']) ? (int)$valuesTmp['priority'] : FreshRSS_Feed::PRIORITY_MAIN_STREAM, PDO::PARAM_INT);
$ok &= $stm->bindValue(':path_entries', mb_strcut($valuesTmp['pathEntries'], 0, 4096, 'UTF-8'), PDO::PARAM_STR);
$ok &= $stm->bindValue(':http_auth', base64_encode($valuesTmp['httpAuth'] ?? ''), PDO::PARAM_STR);
$ok &= $stm->bindValue(':error', isset($valuesTmp['error']) ? (int)$valuesTmp['error'] : 0, PDO::PARAM_INT);
$ok &= $stm->bindValue(':ttl', isset($valuesTmp['ttl']) ? (int)$valuesTmp['ttl'] : FreshRSS_Feed::TTL_DEFAULT, PDO::PARAM_INT);
$ok &= $stm->bindValue(':attributes', is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] :
json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), PDO::PARAM_STR);
}
if ($ok && $stm !== false && $stm->execute()) {
if (empty($valuesTmp['id'])) {
// Auto-generated ID
$feedId = $this->pdo->lastInsertId('`_feed_id_seq`');
return $feedId === false ? false : (int)$feedId;
}
$this->sqlResetSequence();
return $valuesTmp['id'];
} else {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
/** @var array{0:string,1:int,2:string} $info */
if ($this->autoUpdateDb($info)) {
return $this->addFeed($valuesTmp);
}
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
public function addFeedObject(FreshRSS_Feed $feed): int|false {
// Add feed only if we dont find it in DB
$feed_search = $this->searchByUrl($feed->url());
if ($feed_search === null) {
$values = [
'id' => $feed->id(),
'url' => $feed->url(),
'kind' => $feed->kind(),
'category' => $feed->categoryId(),
'name' => $feed->name(true),
'website' => $feed->website(),
'description' => $feed->description(),
'priority' => $feed->priority(),
'lastUpdate' => 0,
'error' => false,
'pathEntries' => $feed->pathEntries(),
'httpAuth' => $feed->httpAuth(),
'ttl' => $feed->ttl(true),
'attributes' => $feed->attributes(),
];
$id = $this->addFeed($values);
if ($id) {
$feed->_id($id);
$feed->faviconPrepare();
}
return $id;
} else {
// The feed already exists so make sure it is not muted
$feed->_ttl($feed_search->ttl());
$feed->_mute(false);
// Merge existing and import attributes
$existingAttributes = $feed_search->attributes();
$importAttributes = $feed->attributes();
$mergedAttributes = array_replace_recursive($existingAttributes, $importAttributes);
$mergedAttributes = array_filter($mergedAttributes, 'is_string', ARRAY_FILTER_USE_KEY);
$feed->_attributes($mergedAttributes);
// Update some values of the existing feed using the import
$values = [
'kind' => $feed->kind(),
'name' => $feed->name(true),
'website' => $feed->website(),
'description' => $feed->description(),
'pathEntries' => $feed->pathEntries(),
'ttl' => $feed->ttl(true),
'attributes' => $feed->attributes(),
];
if (!$this->updateFeed($feed_search->id(), $values)) {
return false;
}
return $feed_search->id();
}
}
/**
* @param array{'url'?:string,'kind'?:int,'category'?:int,'name'?:string,'website'?:string,'description'?:string,'lastUpdate'?:int,'priority'?:int,
* 'pathEntries'?:string,'httpAuth'?:string,'error'?:int,'ttl'?:int,'attributes'?:string|array<string,mixed>} $valuesTmp $valuesTmp
*/
public function updateFeed(int $id, array $valuesTmp): bool {
$values = [];
$originalValues = $valuesTmp;
if (isset($valuesTmp['name'])) {
$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
}
if (isset($valuesTmp['url'])) {
$valuesTmp['url'] = safe_ascii($valuesTmp['url']);
}
if (isset($valuesTmp['website'])) {
$valuesTmp['website'] = safe_ascii($valuesTmp['website']);
}
$set = '';
foreach ($valuesTmp as $key => $v) {
$set .= '`' . $key . '`=?, ';
if ($key === 'httpAuth') {
$valuesTmp[$key] = is_string($v) ? base64_encode($v) : '';
} elseif ($key === 'attributes') {
$valuesTmp[$key] = is_string($valuesTmp[$key]) ? $valuesTmp[$key] : json_encode($valuesTmp[$key], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
}
$set = substr($set, 0, -2);
$sql = <<<SQL
UPDATE `_feed` SET {$set} WHERE id=?
SQL;
$stm = $this->pdo->prepare($sql);
foreach ($valuesTmp as $v) {
$values[] = $v;
}
$values[] = $id;
if ($stm !== false && $stm->execute($values)) {
return true;
} else {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
/** @var array{0:string,1:int,2:string} $info */
if ($this->autoUpdateDb($info)) {
return $this->updateFeed($id, $originalValues);
}
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info) . ' for feed ' . $id);
return false;
}
}
/**
* @param non-empty-string $key
* @param string|array<mixed>|bool|int|null $value
*/
public function updateFeedAttribute(FreshRSS_Feed $feed, string $key, $value): bool {
$feed->_attribute($key, $value);
return $this->updateFeed(
$feed->id(),
['attributes' => $feed->attributes()]
);
}
/**
* @see updateCachedValues()
*/
public function updateLastUpdate(int $id, int $mtime = 0): int|false {
$sql = <<<'SQL'
UPDATE `_feed` SET `lastUpdate`=:last_update, error=0 WHERE id=:id
SQL;
$stm = $this->pdo->prepare($sql);
if ($stm !== false &&
$stm->bindValue(':last_update', $mtime <= 0 ? time() : $mtime, PDO::PARAM_INT) &&
$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
$stm->execute()) {
return $stm->rowCount();
} else {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
return false;
}
}
public function updateLastError(int $id, ?int $mtime = null): int|false {
$sql = <<<'SQL'
UPDATE `_feed` SET error=:last_update WHERE id=:id
SQL;
$stm = $this->pdo->prepare($sql);
if ($stm !== false &&
$stm->bindValue(':last_update', $mtime === null || $mtime < 0 ? time() : $mtime, PDO::PARAM_INT) &&
$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
$stm->execute()) {
return $stm->rowCount();
} else {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
return false;
}
}
public function mute(int $id, bool $value = true): int|false {
$sign = $value ? '-' : '';
$sql = <<<SQL
UPDATE `_feed`
SET ttl = {$sign}ABS(ttl)
WHERE id = :id
SQL;
$stm = $this->pdo->prepare($sql);
if ($stm !== false &&
$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
$stm->execute()) {
return $stm->rowCount();
}
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
public function changeCategory(int $idOldCat, int $idNewCat): int|false {
$catDAO = FreshRSS_Factory::createCategoryDao();
$newCat = $catDAO->searchById($idNewCat);
if ($newCat === null) {
$newCat = $catDAO->getDefault();
}
if ($newCat === null) {
return false;
}
$sql = <<<'SQL'
UPDATE `_feed` SET category=:new_category WHERE category=:old_category
SQL;
$stm = $this->pdo->prepare($sql);
if ($stm !== false &&
$stm->bindValue(':new_category', $newCat->id(), PDO::PARAM_INT) &&
$stm->bindValue(':old_category', $idOldCat, PDO::PARAM_INT) &&
$stm->execute()) {
return $stm->rowCount();
} else {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
public function deleteFeed(int $id): int|false {
$sql = <<<'SQL'
DELETE FROM `_feed` WHERE id=:id
SQL;
$stm = $this->pdo->prepare($sql);
if ($stm !== false &&
$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
$stm->execute()) {
return $stm->rowCount();
} else {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/**
* @param bool|null $muted to include only muted feeds
* @param bool|null $errored to include only errored feeds
*/
public function deleteFeedByCategory(int $id, ?bool $muted = null, ?bool $errored = null): int|false {
$sql = <<<'SQL'
DELETE FROM `_feed` WHERE category=:category
SQL;
if ($muted) {
$sql .= "\n" . <<<'SQL'
AND ttl < 0
SQL;
}
if ($errored) {
$sql .= "\n" . <<<'SQL'
AND error <> 0
SQL;
}
$stm = $this->pdo->prepare($sql);
if ($stm !== false &&
$stm->bindValue(':category', $id, PDO::PARAM_INT) &&
$stm->execute()) {
return $stm->rowCount();
} else {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/** @return Traversable<array{id:int,url:string,kind:int,category:int,name:string,website:string,description:string,lastUpdate:int,priority?:int,
* pathEntries?:string,httpAuth?:string,error:int|bool,ttl?:int,attributes?:string}> */
public function selectAll(): Traversable {
$sql = <<<'SQL'
SELECT id, url, kind, category, name, website, description, `lastUpdate`,
priority, `pathEntries`, `httpAuth`, error, ttl, attributes
FROM `_feed`
SQL;
$stm = $this->pdo->query($sql);
if ($stm !== false) {
while (is_array($row = $stm->fetch(PDO::FETCH_ASSOC))) {
/** @var array{id:int,url:string,kind:int,category:int,name:string,website:string,description:string,lastUpdate:int,priority?:int,
* pathEntries?:string,httpAuth?:string,error:int,ttl?:int,attributes?:string} $row */
yield $row;
}
} else {
$info = $this->pdo->errorInfo();
/** @var array{0:string,1:int,2:string} $info */
if ($this->autoUpdateDb($info)) {
yield from $this->selectAll();
} else {
Minz_Log::error(__METHOD__ . ' error: ' . json_encode($info));
}
}
}
public function searchById(int $id): ?FreshRSS_Feed {
$sql = <<<'SQL'
SELECT * FROM `_feed` WHERE id=:id
SQL;
$res = $this->fetchAssoc($sql, [':id' => $id]);
if (!is_array($res)) {
return null;
}
/** @var list<array{id:int,url:string,kind:int,category:int,name:string,website:string,description:string,lastUpdate:int,priority:int,
* pathEntries:string,httpAuth:string,error:int,ttl:int,attributes?:string,cache_nbUnreads:int,cache_nbEntries:int}> $res */
$feeds = self::daoToFeeds($res);
return $feeds[$id] ?? null;
}
public function searchByUrl(string $url): ?FreshRSS_Feed {
$sql = <<<'SQL'
SELECT * FROM `_feed` WHERE url=:url
SQL;
$res = $this->fetchAssoc($sql, [':url' => $url]);
if (!is_array($res)) {
return null;
}
/** @var list<array{id:int,url:string,kind:int,category:int,name:string,website:string,description:string,lastUpdate:int,priority:int,
* pathEntries:string,httpAuth:string,error:int,ttl:int,attributes?:string,cache_nbUnreads:int,cache_nbEntries:int}> $res */
return empty($res[0]) ? null : (current(self::daoToFeeds($res)) ?: null);
}
/** @return list<int> */
public function listFeedsIds(): array {
$sql = <<<'SQL'
SELECT id FROM `_feed`
SQL;
/** @var list<int> $res */
$res = $this->fetchColumn($sql, 0) ?? [];
return $res;
}
/** @return array<int,FreshRSS_Feed> where the key is the feed ID */
public function listFeeds(): array {
$sql = <<<'SQL'
SELECT * FROM `_feed` ORDER BY name
SQL;
$res = $this->fetchAssoc($sql);
if (!is_array($res)) {
return [];
}
/** @var list<array{id:int,url:string,kind:int,category:int,name:string,website:string,description:string,lastUpdate:int,priority:int,
* pathEntries:string,httpAuth:string,error:int,ttl:int,attributes?:string,cache_nbUnreads:int,cache_nbEntries:int}> $res */
return self::daoToFeeds($res);
}
/** @return array<string,string> */
public function listFeedsNewestItemUsec(?int $id_feed = null): array {
$sql = <<<'SQL'
SELECT id_feed, MAX(id) as newest_item_us FROM `_entry`
SQL;
if ($id_feed === null) {
$sql .= "\n" . <<<'SQL'
GROUP BY id_feed
SQL;
} else {
$sql .= "\n" . <<<SQL
WHERE id_feed=$id_feed
SQL;
}
$res = $this->fetchAssoc($sql);
/** @var list<array{id_feed:int,newest_item_us:string}>|null $res */
if ($res === null) {
return [];
}
$newestItemUsec = [];
foreach ($res as $line) {
$newestItemUsec['f_' . $line['id_feed']] = $line['newest_item_us'];
}
return $newestItemUsec;
}
/**
* @param int $defaultCacheDuration Use -1 to return all feeds, without filtering them by TTL.
* @return array<int,FreshRSS_Feed> where the key is the feed ID
*/
public function listFeedsOrderUpdate(int $defaultCacheDuration = 3600, int $limit = 0): array {
$ttlDefault = FreshRSS_Feed::TTL_DEFAULT;
$refreshThreshold = time() + 60;
$lastAttemptExpression = '(CASE WHEN error > `lastUpdate` THEN error ELSE `lastUpdate` END)';
$sql = <<<SQL
SELECT * FROM `_feed`
SQL;
if ($defaultCacheDuration >= 0) {
$sql .= "\n" . <<<SQL
WHERE ttl >= {$ttlDefault}
AND {$lastAttemptExpression} < ({$refreshThreshold}-(CASE WHEN ttl={$ttlDefault} THEN {$defaultCacheDuration} ELSE ttl END))
SQL;
}
$sql .= "\n" . <<<SQL
ORDER BY {$lastAttemptExpression} ASC
SQL;
if ($limit > 0) {
$sql .= "\n" . <<<SQL
LIMIT {$limit}
SQL;
}
$stm = $this->pdo->query($sql);
if ($stm !== false && ($res = $stm->fetchAll(PDO::FETCH_ASSOC)) !== false) {
/** @var list<array{id?:int,url?:string,kind?:int,category?:int,name?:string,website?:string,description?:string,lastUpdate?:int,priority?:int,
* pathEntries?:string,httpAuth?:string,error?:int,ttl?:int,attributes?:string,cache_nbUnreads?:int,cache_nbEntries?:int}> $res */
return self::daoToFeeds($res);
} else {
$info = $this->pdo->errorInfo();
/** @var array{0:string,1:int,2:string} $info */
if ($this->autoUpdateDb($info)) {
return $this->listFeedsOrderUpdate($defaultCacheDuration, $limit);
}
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return [];
}
}
/** @return list<string> */
public function listTitles(int $id, int $limit = 0): array {
$sql = <<<SQL
SELECT title FROM `_entry` WHERE id_feed=:id_feed ORDER BY id DESC
SQL;
if ($limit > 0) {
$sql .= "\n" . <<<SQL
LIMIT {$limit}
SQL;
}
$res = $this->fetchColumn($sql, 0, [':id_feed' => $id]) ?? [];
/** @var list<string> $res */
return $res;
}
/**
* @param bool|null $muted to include only muted feeds
* @param bool|null $errored to include only errored feeds
* @return array<int,FreshRSS_Feed> where the key is the feed ID
*/
public function listByCategory(int $cat, ?bool $muted = null, ?bool $errored = null): array {
$sql = <<<'SQL'
SELECT * FROM `_feed` WHERE category=:category
SQL;
if ($muted) {
$sql .= "\n" . <<<SQL
AND ttl < 0
SQL;
}
if ($errored) {
$sql .= "\n" . <<<SQL
AND error <> 0
SQL;
}
$res = $this->fetchAssoc($sql, [':category' => $cat]);
if (!is_array($res)) {
return [];
}
/** @var list<array{id:int,url:string,kind:int,category:int,name:string,website:string,description:string,lastUpdate:int,priority:int,
* pathEntries:string,httpAuth:string,error:int,ttl:int,attributes?:string,cache_nbUnreads:int,cache_nbEntries:int}> $res */
$feeds = self::daoToFeeds($res);
uasort($feeds, static fn(FreshRSS_Feed $a, FreshRSS_Feed $b) => FreshRSS_Context::localeCompare($a->name(), $b->name()));
return $feeds;
}
public function countEntries(int $id): int {
$sql = <<<'SQL'
SELECT COUNT(*) AS count FROM `_entry` WHERE id_feed=:id_feed
SQL;
return $this->fetchInt($sql, ['id_feed' => $id]) ?? -1;
}
public function countNotRead(int $id): int {
$sql = <<<'SQL'
SELECT COUNT(*) AS count FROM `_entry` WHERE id_feed=:id_feed AND is_read=0
SQL;
return $this->fetchInt($sql, ['id_feed' => $id]) ?? -1;
}
/** @return int Timestamp of the newest article received for the specified feed, or 0 if none */
public function newestArticleReceivedDate(int $feedId): int {
$sql = <<<'SQL'
SELECT MAX(id) / 1000000 AS t FROM `_entry` WHERE id_feed=:id_feed
SQL;
return $this->fetchInt($sql, ['id_feed' => $feedId]) ?? 0;
}
/** @return int Timestamp of the Last article published for the specified feed, or 0 if none */
public function newestArticlePublicationDate(int $feedId): int {
$sql = <<<'SQL'
SELECT MAX(date) AS t FROM `_entry` WHERE id_feed=:id_feed
SQL;
return $this->fetchInt($sql, ['id_feed' => $feedId]) ?? 0;
}
/**
* Update cached values for selected feeds, or all feeds if no feed ID is provided.
*/
public function updateCachedValues(int ...$feedIds): int|false {
if (empty($feedIds)) {
$whereFeedIds = 'true';
$whereEntryIdFeeds = 'true';
} else {
$whereFeedIds = 'id IN (' . str_repeat('?,', count($feedIds) - 1) . '?)';
$whereEntryIdFeeds = 'id_feed IN (' . str_repeat('?,', count($feedIds) - 1) . '?)';
}
$sql = <<<SQL
UPDATE `_feed`
LEFT JOIN (
SELECT
id_feed,
COUNT(*) AS total_entries,
SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS unread_entries
FROM `_entry`
WHERE $whereEntryIdFeeds
GROUP BY id_feed
) AS entry_counts ON entry_counts.id_feed = `_feed`.id
SET `cache_nbEntries` = COALESCE(entry_counts.total_entries, 0),
`cache_nbUnreads` = COALESCE(entry_counts.unread_entries, 0)
WHERE $whereFeedIds
SQL;
$stm = $this->pdo->prepare($sql);
if ($stm !== false && $stm->execute(array_merge($feedIds, $feedIds))) {
return $stm->rowCount();
} else {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/**
* Remember to call updateCachedValues() after calling this function
* @return int|false number of lines affected or false in case of error
*/
public function markAsReadMaxUnread(int $id, int $n): int|false {
//Double SELECT for MySQL workaround ERROR 1093 (HY000)
$sql = <<<'SQL'
UPDATE `_entry` SET is_read=1
WHERE id_feed=:id_feed1 AND is_read=0 AND id <= (SELECT e3.id FROM (
SELECT e2.id FROM `_entry` e2
WHERE e2.id_feed=:id_feed2 AND e2.is_read=0
ORDER BY e2.id DESC
LIMIT 1
OFFSET :limit) e3)
SQL;
if (($stm = $this->pdo->prepare($sql)) !== false &&
$stm->bindValue(':id_feed1', $id, PDO::PARAM_INT) &&
$stm->bindValue(':id_feed2', $id, PDO::PARAM_INT) &&
$stm->bindValue(':limit', $n, PDO::PARAM_INT) &&
$stm->execute()) {
return $stm->rowCount();
} else {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
/**
* Remember to call updateCachedValues() after calling this function
* @return int|false number of lines affected or false in case of error
*/
public function markAsReadNotSeen(int $id, int $minLastSeen): int|false {
$sql = <<<'SQL'
UPDATE `_entry` SET is_read=1
WHERE id_feed=:id_feed AND is_read=0 AND (`lastSeen` + 10 < :min_last_seen)
SQL;
if (($stm = $this->pdo->prepare($sql)) !== false &&
$stm->bindValue(':id_feed', $id, PDO::PARAM_INT) &&
$stm->bindValue(':min_last_seen', $minLastSeen, PDO::PARAM_INT) &&
$stm->execute()) {
return $stm->rowCount();
} else {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
return false;
}
}
public function truncate(int $id): int|false {
$sql = <<<'SQL'
DELETE FROM `_entry` WHERE id_feed=:id
SQL;
$stm = $this->pdo->prepare($sql);
$this->pdo->beginTransaction();
if (!($stm !== false &&
$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
$stm->execute())) {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
$this->pdo->rollBack();
return false;
}
$affected = $stm->rowCount();
$sql = <<<'SQL'
UPDATE `_feed` SET `cache_nbEntries`=0, `cache_nbUnreads`=0, `lastUpdate`=0 WHERE id=:id
SQL;
$stm = $this->pdo->prepare($sql);
if (!($stm !== false &&
$stm->bindValue(':id', $id, PDO::PARAM_INT) &&
$stm->execute())) {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
$this->pdo->rollBack();
return false;
}
$this->pdo->commit();
return $affected;
}
public function purge(): bool {
$sql = <<<'SQL'
DELETE FROM `_entry`
SQL;
$stm = $this->pdo->prepare($sql);
$this->pdo->beginTransaction();
if ($stm === false || !$stm->execute()) {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
$this->pdo->rollBack();
return false;
}
$sql = <<<'SQL'
UPDATE `_feed` SET `cache_nbEntries` = 0, `cache_nbUnreads` = 0
SQL;
$stm = $this->pdo->prepare($sql);
if ($stm === false || !$stm->execute()) {
$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error ' . __METHOD__ . ' B ' . json_encode($info));
$this->pdo->rollBack();
return false;
}
return $this->pdo->commit();
}
/**
* @param array<array{id?:int,url?:string,kind?:int,category?:int,name?:string,website?:string,description?:string,lastUpdate?:int,priority?:int,
* pathEntries?:string,httpAuth?:string,error?:int,ttl?:int,attributes?:string,cache_nbUnreads?:int,cache_nbEntries?:int}> $listDAO
* @return array<int,FreshRSS_Feed> where the key is the feed ID
*/
public static function daoToFeeds(array $listDAO, ?int $catID = null): array {
$list = [];
foreach ($listDAO as $dao) {
if (!is_string($dao['name'] ?? null)) {
continue;
}
if ($catID === null) {
$category = is_numeric($dao['category'] ?? null) ? (int)$dao['category'] : 0;
} else {
$category = $catID;
}
$myFeed = new FreshRSS_Feed($dao['url'] ?? '', false);
$myFeed->_kind($dao['kind'] ?? FreshRSS_Feed::KIND_RSS);
$myFeed->_categoryId($category);
$myFeed->_name($dao['name']);
$myFeed->_website($dao['website'] ?? '', false);
$myFeed->_description($dao['description'] ?? '');
$myFeed->_lastUpdate($dao['lastUpdate'] ?? 0);
$myFeed->_priority($dao['priority'] ?? 10);
$myFeed->_pathEntries($dao['pathEntries'] ?? '');
$myFeed->_httpAuth(base64_decode($dao['httpAuth'] ?? '', true) ?: '');
$myFeed->_error($dao['error'] ?? 0);
$myFeed->_ttl($dao['ttl'] ?? FreshRSS_Feed::TTL_DEFAULT);
$myFeed->_attributes($dao['attributes'] ?? '');
$myFeed->_nbNotRead($dao['cache_nbUnreads'] ?? -1);
$myFeed->_nbEntries($dao['cache_nbEntries'] ?? -1);
if (isset($dao['id'])) {
$myFeed->_id($dao['id']);
}
$list[$myFeed->id()] = $myFeed;
}
return $list;
}
public function count(): int {
$sql = <<<'SQL'
SELECT COUNT(e.id) AS count FROM `_feed` e
SQL;
return $this->fetchInt($sql) ?? -1;
}
}