4
0

FeedDAO.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. <?php
  2. declare(strict_types=1);
  3. class FreshRSS_FeedDAO extends Minz_ModelPdo {
  4. public function sqlResetSequence(): bool {
  5. return true; // Nothing to do for MySQL
  6. }
  7. protected function addColumn(string $name): bool {
  8. if ($this->pdo->inTransaction()) {
  9. $this->pdo->commit();
  10. }
  11. Minz_Log::warning(__METHOD__ . ': ' . $name);
  12. try {
  13. if ($name === 'kind') { //v1.20.0
  14. return $this->pdo->exec('ALTER TABLE `_feed` ADD COLUMN kind SMALLINT DEFAULT 0') !== false;
  15. }
  16. } catch (Exception $e) {
  17. Minz_Log::error(__METHOD__ . ' error: ' . $e->getMessage());
  18. }
  19. return false;
  20. }
  21. /** @param array{0:string,1:int,2:string} $errorInfo */
  22. public function autoUpdateDb(array $errorInfo): bool {
  23. if (isset($errorInfo[0])) {
  24. if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
  25. $errorLines = explode("\n", $errorInfo[2], 2); // The relevant column name is on the first line, other lines are noise
  26. foreach (['kind'] as $column) {
  27. if (str_contains($errorLines[0], $column)) {
  28. return $this->addColumn($column);
  29. }
  30. }
  31. }
  32. }
  33. return false;
  34. }
  35. /**
  36. * @param array{id?:int,url:string,kind:int,category:int,name:string,website:string,description:string,lastUpdate:int,priority?:int,
  37. * pathEntries?:string,httpAuth:string,error:int|bool,ttl?:int,attributes?:string|array<string|mixed>} $valuesTmp
  38. */
  39. public function addFeed(array $valuesTmp): int|false {
  40. if (empty($valuesTmp['id'])) { // Auto-generated ID
  41. $sql = <<<'SQL'
  42. INSERT INTO `_feed` (url, kind, category, name, website, description, `lastUpdate`, priority, `pathEntries`, `httpAuth`, error, ttl, attributes)
  43. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  44. SQL;
  45. } else {
  46. $sql = <<<'SQL'
  47. INSERT INTO `_feed` (id, url, kind, category, name, website, description, `lastUpdate`, priority, `pathEntries`, `httpAuth`, error, ttl, attributes)
  48. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  49. SQL;
  50. }
  51. $stm = $this->pdo->prepare($sql);
  52. $valuesTmp['url'] = safe_ascii($valuesTmp['url']);
  53. $valuesTmp['website'] = safe_ascii($valuesTmp['website']);
  54. if (!isset($valuesTmp['pathEntries'])) {
  55. $valuesTmp['pathEntries'] = '';
  56. }
  57. if (!isset($valuesTmp['attributes'])) {
  58. $valuesTmp['attributes'] = [];
  59. }
  60. $values = empty($valuesTmp['id']) ? [] : [$valuesTmp['id']];
  61. $values = array_merge($values, [
  62. $valuesTmp['url'],
  63. $valuesTmp['kind'] ?? FreshRSS_Feed::KIND_RSS,
  64. $valuesTmp['category'],
  65. mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8'),
  66. $valuesTmp['website'],
  67. FreshRSS_SimplePieCustom::sanitizeHTML($valuesTmp['description'], ''),
  68. $valuesTmp['lastUpdate'],
  69. isset($valuesTmp['priority']) ? (int)$valuesTmp['priority'] : FreshRSS_Feed::PRIORITY_MAIN_STREAM,
  70. mb_strcut($valuesTmp['pathEntries'], 0, 4096, 'UTF-8'),
  71. base64_encode($valuesTmp['httpAuth']),
  72. isset($valuesTmp['error']) ? (int)$valuesTmp['error'] : 0,
  73. isset($valuesTmp['ttl']) ? (int)$valuesTmp['ttl'] : FreshRSS_Feed::TTL_DEFAULT,
  74. is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
  75. ]);
  76. if ($stm !== false && $stm->execute($values)) {
  77. if (empty($valuesTmp['id'])) {
  78. // Auto-generated ID
  79. $feedId = $this->pdo->lastInsertId('`_feed_id_seq`');
  80. return $feedId === false ? false : (int)$feedId;
  81. }
  82. $this->sqlResetSequence();
  83. return $valuesTmp['id'];
  84. } else {
  85. $info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
  86. /** @var array{0:string,1:int,2:string} $info */
  87. if ($this->autoUpdateDb($info)) {
  88. return $this->addFeed($valuesTmp);
  89. }
  90. Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
  91. return false;
  92. }
  93. }
  94. public function addFeedObject(FreshRSS_Feed $feed): int|false {
  95. // Add feed only if we don’t find it in DB
  96. $feed_search = $this->searchByUrl($feed->url());
  97. if ($feed_search === null) {
  98. $values = [
  99. 'id' => $feed->id(),
  100. 'url' => $feed->url(),
  101. 'kind' => $feed->kind(),
  102. 'category' => $feed->categoryId(),
  103. 'name' => $feed->name(true),
  104. 'website' => $feed->website(),
  105. 'description' => $feed->description(),
  106. 'priority' => $feed->priority(),
  107. 'lastUpdate' => 0,
  108. 'error' => false,
  109. 'pathEntries' => $feed->pathEntries(),
  110. 'httpAuth' => $feed->httpAuth(),
  111. 'ttl' => $feed->ttl(true),
  112. 'attributes' => $feed->attributes(),
  113. ];
  114. $id = $this->addFeed($values);
  115. if ($id) {
  116. $feed->_id($id);
  117. $feed->faviconPrepare();
  118. }
  119. return $id;
  120. } else {
  121. // The feed already exists so make sure it is not muted
  122. $feed->_ttl($feed_search->ttl());
  123. $feed->_mute(false);
  124. // Merge existing and import attributes
  125. $existingAttributes = $feed_search->attributes();
  126. $importAttributes = $feed->attributes();
  127. $mergedAttributes = array_replace_recursive($existingAttributes, $importAttributes);
  128. $mergedAttributes = array_filter($mergedAttributes, 'is_string', ARRAY_FILTER_USE_KEY);
  129. $feed->_attributes($mergedAttributes);
  130. // Update some values of the existing feed using the import
  131. $values = [
  132. 'kind' => $feed->kind(),
  133. 'name' => $feed->name(true),
  134. 'website' => $feed->website(),
  135. 'description' => $feed->description(),
  136. 'pathEntries' => $feed->pathEntries(),
  137. 'ttl' => $feed->ttl(true),
  138. 'attributes' => $feed->attributes(),
  139. ];
  140. if (!$this->updateFeed($feed_search->id(), $values)) {
  141. return false;
  142. }
  143. return $feed_search->id();
  144. }
  145. }
  146. /**
  147. * @param array{'url'?:string,'kind'?:int,'category'?:int,'name'?:string,'website'?:string,'description'?:string,'lastUpdate'?:int,'priority'?:int,
  148. * 'pathEntries'?:string,'httpAuth'?:string,'error'?:int,'ttl'?:int,'attributes'?:string|array<string,mixed>} $valuesTmp $valuesTmp
  149. */
  150. public function updateFeed(int $id, array $valuesTmp): bool {
  151. $values = [];
  152. $originalValues = $valuesTmp;
  153. if (isset($valuesTmp['name'])) {
  154. $valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
  155. }
  156. if (isset($valuesTmp['url'])) {
  157. $valuesTmp['url'] = safe_ascii($valuesTmp['url']);
  158. }
  159. if (isset($valuesTmp['website'])) {
  160. $valuesTmp['website'] = safe_ascii($valuesTmp['website']);
  161. }
  162. $set = '';
  163. foreach ($valuesTmp as $key => $v) {
  164. $set .= '`' . $key . '`=?, ';
  165. if ($key === 'httpAuth') {
  166. $valuesTmp[$key] = base64_encode($v);
  167. } elseif ($key === 'attributes') {
  168. $valuesTmp[$key] = is_string($valuesTmp[$key]) ? $valuesTmp[$key] : json_encode($valuesTmp[$key], JSON_UNESCAPED_SLASHES);
  169. }
  170. }
  171. $set = substr($set, 0, -2);
  172. $sql = 'UPDATE `_feed` SET ' . $set . ' WHERE id=?';
  173. $stm = $this->pdo->prepare($sql);
  174. foreach ($valuesTmp as $v) {
  175. $values[] = $v;
  176. }
  177. $values[] = $id;
  178. if ($stm !== false && $stm->execute($values)) {
  179. return true;
  180. } else {
  181. $info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
  182. /** @var array{0:string,1:int,2:string} $info */
  183. if ($this->autoUpdateDb($info)) {
  184. return $this->updateFeed($id, $originalValues);
  185. }
  186. Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info) . ' for feed ' . $id);
  187. return false;
  188. }
  189. }
  190. /**
  191. * @param non-empty-string $key
  192. * @param string|array<mixed>|bool|int|null $value
  193. */
  194. public function updateFeedAttribute(FreshRSS_Feed $feed, string $key, $value): bool {
  195. $feed->_attribute($key, $value);
  196. return $this->updateFeed(
  197. $feed->id(),
  198. ['attributes' => $feed->attributes()]
  199. );
  200. }
  201. /**
  202. * @see updateCachedValues()
  203. */
  204. public function updateLastUpdate(int $id, bool $inError = false, int $mtime = 0): int|false {
  205. $sql = 'UPDATE `_feed` SET `lastUpdate`=?, error=? WHERE id=?';
  206. $values = [
  207. $mtime <= 0 ? time() : $mtime,
  208. $inError ? 1 : 0,
  209. $id,
  210. ];
  211. $stm = $this->pdo->prepare($sql);
  212. if ($stm !== false && $stm->execute($values)) {
  213. return $stm->rowCount();
  214. } else {
  215. $info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
  216. Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
  217. return false;
  218. }
  219. }
  220. public function mute(int $id, bool $value = true): int|false {
  221. $sql = 'UPDATE `_feed` SET ttl=' . ($value ? '-' : '') . 'ABS(ttl) WHERE id=' . intval($id);
  222. return $this->pdo->exec($sql);
  223. }
  224. public function changeCategory(int $idOldCat, int $idNewCat): int|false {
  225. $catDAO = FreshRSS_Factory::createCategoryDao();
  226. $newCat = $catDAO->searchById($idNewCat);
  227. if ($newCat === null) {
  228. $newCat = $catDAO->getDefault();
  229. }
  230. if ($newCat === null) {
  231. return false;
  232. }
  233. $sql = 'UPDATE `_feed` SET category=? WHERE category=?';
  234. $stm = $this->pdo->prepare($sql);
  235. $values = [
  236. $newCat->id(),
  237. $idOldCat,
  238. ];
  239. if ($stm !== false && $stm->execute($values)) {
  240. return $stm->rowCount();
  241. } else {
  242. $info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
  243. Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
  244. return false;
  245. }
  246. }
  247. public function deleteFeed(int $id): int|false {
  248. $sql = 'DELETE FROM `_feed` WHERE id=?';
  249. $stm = $this->pdo->prepare($sql);
  250. $values = [$id];
  251. if ($stm !== false && $stm->execute($values)) {
  252. return $stm->rowCount();
  253. } else {
  254. $info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
  255. Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
  256. return false;
  257. }
  258. }
  259. /**
  260. * @param bool|null $muted to include only muted feeds
  261. * @param bool|null $errored to include only errored feeds
  262. */
  263. public function deleteFeedByCategory(int $id, ?bool $muted = null, ?bool $errored = null): int|false {
  264. $sql = 'DELETE FROM `_feed` WHERE category=?';
  265. if ($muted) {
  266. $sql .= ' AND ttl < 0';
  267. }
  268. if ($errored) {
  269. $sql .= ' AND error <> 0';
  270. }
  271. $stm = $this->pdo->prepare($sql);
  272. $values = [$id];
  273. if ($stm !== false && $stm->execute($values)) {
  274. return $stm->rowCount();
  275. } else {
  276. $info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
  277. Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
  278. return false;
  279. }
  280. }
  281. /** @return Traversable<array{id:int,url:string,kind:int,category:int,name:string,website:string,description:string,lastUpdate:int,priority?:int,
  282. * pathEntries?:string,httpAuth:string,error:int|bool,ttl?:int,attributes?:string}> */
  283. public function selectAll(): Traversable {
  284. $sql = <<<'SQL'
  285. SELECT id, url, kind, category, name, website, description, `lastUpdate`,
  286. priority, `pathEntries`, `httpAuth`, error, ttl, attributes
  287. FROM `_feed`
  288. SQL;
  289. $stm = $this->pdo->query($sql);
  290. if ($stm !== false) {
  291. while (is_array($row = $stm->fetch(PDO::FETCH_ASSOC))) {
  292. /** @var array{id:int,url:string,kind:int,category:int,name:string,website:string,description:string,lastUpdate:int,priority?:int,
  293. * pathEntries?:string,httpAuth:string,error:int,ttl?:int,attributes?:string} $row */
  294. yield $row;
  295. }
  296. } else {
  297. $info = $this->pdo->errorInfo();
  298. /** @var array{0:string,1:int,2:string} $info */
  299. if ($this->autoUpdateDb($info)) {
  300. yield from $this->selectAll();
  301. } else {
  302. Minz_Log::error(__METHOD__ . ' error: ' . json_encode($info));
  303. }
  304. }
  305. }
  306. public function searchById(int $id): ?FreshRSS_Feed {
  307. $sql = 'SELECT * FROM `_feed` WHERE id=:id';
  308. $res = $this->fetchAssoc($sql, [':id' => $id]);
  309. if (!is_array($res)) {
  310. return null;
  311. }
  312. /** @var list<array{id:int,url:string,kind:int,category:int,name:string,website:string,description:string,lastUpdate:int,priority:int,
  313. * pathEntries:string,httpAuth:string,error:int,ttl:int,attributes?:string,cache_nbUnreads:int,cache_nbEntries:int}> $res */
  314. $feeds = self::daoToFeeds($res);
  315. return $feeds[$id] ?? null;
  316. }
  317. public function searchByUrl(string $url): ?FreshRSS_Feed {
  318. $sql = 'SELECT * FROM `_feed` WHERE url=:url';
  319. $res = $this->fetchAssoc($sql, [':url' => $url]);
  320. if (!is_array($res)) {
  321. return null;
  322. }
  323. /** @var list<array{id:int,url:string,kind:int,category:int,name:string,website:string,description:string,lastUpdate:int,priority:int,
  324. * pathEntries:string,httpAuth:string,error:int,ttl:int,attributes?:string,cache_nbUnreads:int,cache_nbEntries:int}> $res */
  325. return empty($res[0]) ? null : (current(self::daoToFeeds($res)) ?: null);
  326. }
  327. /** @return list<int> */
  328. public function listFeedsIds(): array {
  329. $sql = 'SELECT id FROM `_feed`';
  330. /** @var list<int> $res */
  331. $res = $this->fetchColumn($sql, 0) ?? [];
  332. return $res;
  333. }
  334. /** @return array<int,FreshRSS_Feed> where the key is the feed ID */
  335. public function listFeeds(): array {
  336. $sql = 'SELECT * FROM `_feed` ORDER BY name';
  337. $res = $this->fetchAssoc($sql);
  338. if (!is_array($res)) {
  339. return [];
  340. }
  341. /** @var list<array{id:int,url:string,kind:int,category:int,name:string,website:string,description:string,lastUpdate:int,priority:int,
  342. * pathEntries:string,httpAuth:string,error:int,ttl:int,attributes?:string,cache_nbUnreads:int,cache_nbEntries:int}> $res */
  343. return self::daoToFeeds($res);
  344. }
  345. /** @return array<string,string> */
  346. public function listFeedsNewestItemUsec(?int $id_feed = null): array {
  347. $sql = 'SELECT id_feed, MAX(id) as newest_item_us FROM `_entry` ';
  348. if ($id_feed === null) {
  349. $sql .= 'GROUP BY id_feed';
  350. } else {
  351. $sql .= 'WHERE id_feed=' . intval($id_feed);
  352. }
  353. $res = $this->fetchAssoc($sql);
  354. /** @var list<array{id_feed:int,newest_item_us:string}>|null $res */
  355. if ($res === null) {
  356. return [];
  357. }
  358. $newestItemUsec = [];
  359. foreach ($res as $line) {
  360. $newestItemUsec['f_' . $line['id_feed']] = $line['newest_item_us'];
  361. }
  362. return $newestItemUsec;
  363. }
  364. /**
  365. * @param int $defaultCacheDuration Use -1 to return all feeds, without filtering them by TTL.
  366. * @return array<int,FreshRSS_Feed> where the key is the feed ID
  367. */
  368. public function listFeedsOrderUpdate(int $defaultCacheDuration = 3600, int $limit = 0): array {
  369. $sql = 'SELECT * FROM `_feed` '
  370. . ($defaultCacheDuration < 0 ? '' : 'WHERE ttl >= ' . FreshRSS_Feed::TTL_DEFAULT
  371. . ' AND `lastUpdate` < (' . (time() + 60)
  372. . '-(CASE WHEN ttl=' . FreshRSS_Feed::TTL_DEFAULT . ' THEN ' . intval($defaultCacheDuration) . ' ELSE ttl END)) ')
  373. . 'ORDER BY `lastUpdate` '
  374. . ($limit < 1 ? '' : 'LIMIT ' . intval($limit));
  375. $stm = $this->pdo->query($sql);
  376. if ($stm !== false && ($res = $stm->fetchAll(PDO::FETCH_ASSOC)) !== false) {
  377. /** @var list<array{id?:int,url?:string,kind?:int,category?:int,name?:string,website?:string,description?:string,lastUpdate?:int,priority?:int,
  378. * pathEntries?:string,httpAuth?:string,error?:int,ttl?:int,attributes?:string,cache_nbUnreads?:int,cache_nbEntries?:int}> $res */
  379. return self::daoToFeeds($res);
  380. } else {
  381. $info = $this->pdo->errorInfo();
  382. /** @var array{0:string,1:int,2:string} $info */
  383. if ($this->autoUpdateDb($info)) {
  384. return $this->listFeedsOrderUpdate($defaultCacheDuration, $limit);
  385. }
  386. Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
  387. return [];
  388. }
  389. }
  390. /** @return list<string> */
  391. public function listTitles(int $id, int $limit = 0): array {
  392. $sql = 'SELECT title FROM `_entry` WHERE id_feed=:id_feed ORDER BY id DESC'
  393. . ($limit < 1 ? '' : ' LIMIT ' . intval($limit));
  394. $res = $this->fetchColumn($sql, 0, [':id_feed' => $id]) ?? [];
  395. /** @var list<string> $res */
  396. return $res;
  397. }
  398. /**
  399. * @param bool|null $muted to include only muted feeds
  400. * @param bool|null $errored to include only errored feeds
  401. * @return array<int,FreshRSS_Feed> where the key is the feed ID
  402. */
  403. public function listByCategory(int $cat, ?bool $muted = null, ?bool $errored = null): array {
  404. $sql = 'SELECT * FROM `_feed` WHERE category=:category';
  405. if ($muted) {
  406. $sql .= ' AND ttl < 0';
  407. }
  408. if ($errored) {
  409. $sql .= ' AND error <> 0';
  410. }
  411. $res = $this->fetchAssoc($sql, [':category' => $cat]);
  412. if (!is_array($res)) {
  413. return [];
  414. }
  415. /** @var list<array{id:int,url:string,kind:int,category:int,name:string,website:string,description:string,lastUpdate:int,priority:int,
  416. * pathEntries:string,httpAuth:string,error:int,ttl:int,attributes?:string,cache_nbUnreads:int,cache_nbEntries:int}> $res */
  417. $feeds = self::daoToFeeds($res);
  418. uasort($feeds, static fn(FreshRSS_Feed $a, FreshRSS_Feed $b) => strnatcasecmp($a->name(), $b->name()));
  419. return $feeds;
  420. }
  421. public function countEntries(int $id): int {
  422. $sql = 'SELECT COUNT(*) AS count FROM `_entry` WHERE id_feed=:id_feed';
  423. $res = $this->fetchColumn($sql, 0, ['id_feed' => $id]);
  424. return isset($res[0]) ? (int)($res[0]) : -1;
  425. }
  426. public function countNotRead(int $id): int {
  427. $sql = 'SELECT COUNT(*) AS count FROM `_entry` WHERE id_feed=:id_feed AND is_read=0';
  428. $res = $this->fetchColumn($sql, 0, ['id_feed' => $id]);
  429. return isset($res[0]) ? (int)($res[0]) : -1;
  430. }
  431. /**
  432. * Update cached values for selected feeds, or all feeds if no feed ID is provided.
  433. */
  434. public function updateCachedValues(int ...$feedIds): int|false {
  435. if (empty($feedIds)) {
  436. $whereFeedIds = 'true';
  437. $whereEntryIdFeeds = 'true';
  438. } else {
  439. $whereFeedIds = 'id IN (' . str_repeat('?,', count($feedIds) - 1) . '?)';
  440. $whereEntryIdFeeds = 'id_feed IN (' . str_repeat('?,', count($feedIds) - 1) . '?)';
  441. }
  442. $sql = <<<SQL
  443. UPDATE `_feed`
  444. LEFT JOIN (
  445. SELECT
  446. id_feed,
  447. COUNT(*) AS total_entries,
  448. SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS unread_entries
  449. FROM `_entry`
  450. WHERE $whereEntryIdFeeds
  451. GROUP BY id_feed
  452. ) AS entry_counts ON entry_counts.id_feed = `_feed`.id
  453. SET `cache_nbEntries` = COALESCE(entry_counts.total_entries, 0),
  454. `cache_nbUnreads` = COALESCE(entry_counts.unread_entries, 0)
  455. WHERE $whereFeedIds
  456. SQL;
  457. $stm = $this->pdo->prepare($sql);
  458. if ($stm !== false && $stm->execute(array_merge($feedIds, $feedIds))) {
  459. return $stm->rowCount();
  460. } else {
  461. $info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
  462. Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
  463. return false;
  464. }
  465. }
  466. /**
  467. * Remember to call updateCachedValues() after calling this function
  468. * @return int|false number of lines affected or false in case of error
  469. */
  470. public function markAsReadMaxUnread(int $id, int $n): int|false {
  471. //Double SELECT for MySQL workaround ERROR 1093 (HY000)
  472. $sql = <<<'SQL'
  473. UPDATE `_entry` SET is_read=1
  474. WHERE id_feed=:id_feed1 AND is_read=0 AND id <= (SELECT e3.id FROM (
  475. SELECT e2.id FROM `_entry` e2
  476. WHERE e2.id_feed=:id_feed2 AND e2.is_read=0
  477. ORDER BY e2.id DESC
  478. LIMIT 1
  479. OFFSET :limit) e3)
  480. SQL;
  481. if (($stm = $this->pdo->prepare($sql)) !== false &&
  482. $stm->bindParam(':id_feed1', $id, PDO::PARAM_INT) &&
  483. $stm->bindParam(':id_feed2', $id, PDO::PARAM_INT) &&
  484. $stm->bindParam(':limit', $n, PDO::PARAM_INT) &&
  485. $stm->execute()) {
  486. return $stm->rowCount();
  487. } else {
  488. $info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
  489. Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
  490. return false;
  491. }
  492. }
  493. /**
  494. * Remember to call updateCachedValues() after calling this function
  495. * @return int|false number of lines affected or false in case of error
  496. */
  497. public function markAsReadNotSeen(int $id, int $minLastSeen): int|false {
  498. $sql = <<<'SQL'
  499. UPDATE `_entry` SET is_read=1
  500. WHERE id_feed=:id_feed AND is_read=0 AND (`lastSeen` + 10 < :min_last_seen)
  501. SQL;
  502. if (($stm = $this->pdo->prepare($sql)) !== false &&
  503. $stm->bindValue(':id_feed', $id, PDO::PARAM_INT) &&
  504. $stm->bindValue(':min_last_seen', $minLastSeen, PDO::PARAM_INT) &&
  505. $stm->execute()) {
  506. return $stm->rowCount();
  507. } else {
  508. $info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
  509. Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
  510. return false;
  511. }
  512. }
  513. public function truncate(int $id): int|false {
  514. $sql = 'DELETE FROM `_entry` WHERE id_feed=:id';
  515. $stm = $this->pdo->prepare($sql);
  516. $this->pdo->beginTransaction();
  517. if (!($stm !== false &&
  518. $stm->bindParam(':id', $id, PDO::PARAM_INT) &&
  519. $stm->execute())) {
  520. $info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
  521. Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
  522. $this->pdo->rollBack();
  523. return false;
  524. }
  525. $affected = $stm->rowCount();
  526. $sql = 'UPDATE `_feed` SET `cache_nbEntries`=0, `cache_nbUnreads`=0, `lastUpdate`=0 WHERE id=:id';
  527. $stm = $this->pdo->prepare($sql);
  528. if (!($stm !== false &&
  529. $stm->bindParam(':id', $id, PDO::PARAM_INT) &&
  530. $stm->execute())) {
  531. $info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
  532. Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
  533. $this->pdo->rollBack();
  534. return false;
  535. }
  536. $this->pdo->commit();
  537. return $affected;
  538. }
  539. public function purge(): bool {
  540. $sql = 'DELETE FROM `_entry`';
  541. $stm = $this->pdo->prepare($sql);
  542. $this->pdo->beginTransaction();
  543. if ($stm === false || !$stm->execute()) {
  544. $info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
  545. Minz_Log::error('SQL error ' . __METHOD__ . ' A ' . json_encode($info));
  546. $this->pdo->rollBack();
  547. return false;
  548. }
  549. $sql = 'UPDATE `_feed` SET `cache_nbEntries` = 0, `cache_nbUnreads` = 0';
  550. $stm = $this->pdo->prepare($sql);
  551. if ($stm === false || !$stm->execute()) {
  552. $info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
  553. Minz_Log::error('SQL error ' . __METHOD__ . ' B ' . json_encode($info));
  554. $this->pdo->rollBack();
  555. return false;
  556. }
  557. return $this->pdo->commit();
  558. }
  559. /**
  560. * @param array<array{id?:int,url?:string,kind?:int,category?:int,name?:string,website?:string,description?:string,lastUpdate?:int,priority?:int,
  561. * pathEntries?:string,httpAuth?:string,error?:int|bool,ttl?:int,attributes?:string,cache_nbUnreads?:int,cache_nbEntries?:int}> $listDAO
  562. * @return array<int,FreshRSS_Feed> where the key is the feed ID
  563. */
  564. public static function daoToFeeds(array $listDAO, ?int $catID = null): array {
  565. $list = [];
  566. foreach ($listDAO as $dao) {
  567. if (!is_string($dao['name'] ?? null)) {
  568. continue;
  569. }
  570. if ($catID === null) {
  571. $category = is_numeric($dao['category'] ?? null) ? (int)$dao['category'] : 0;
  572. } else {
  573. $category = $catID;
  574. }
  575. $myFeed = new FreshRSS_Feed($dao['url'] ?? '', false);
  576. $myFeed->_kind($dao['kind'] ?? FreshRSS_Feed::KIND_RSS);
  577. $myFeed->_categoryId($category);
  578. $myFeed->_name($dao['name']);
  579. $myFeed->_website($dao['website'] ?? '', false);
  580. $myFeed->_description($dao['description'] ?? '');
  581. $myFeed->_lastUpdate($dao['lastUpdate'] ?? 0);
  582. $myFeed->_priority($dao['priority'] ?? 10);
  583. $myFeed->_pathEntries($dao['pathEntries'] ?? '');
  584. $myFeed->_httpAuth(base64_decode($dao['httpAuth'] ?? '', true) ?: '');
  585. $myFeed->_error($dao['error'] ?? 0);
  586. $myFeed->_ttl($dao['ttl'] ?? FreshRSS_Feed::TTL_DEFAULT);
  587. $myFeed->_attributes($dao['attributes'] ?? '');
  588. $myFeed->_nbNotRead($dao['cache_nbUnreads'] ?? -1);
  589. $myFeed->_nbEntries($dao['cache_nbEntries'] ?? -1);
  590. if (isset($dao['id'])) {
  591. $myFeed->_id($dao['id']);
  592. }
  593. $list[$myFeed->id()] = $myFeed;
  594. }
  595. return $list;
  596. }
  597. public function count(): int {
  598. $sql = 'SELECT COUNT(e.id) AS count FROM `_feed` e';
  599. $stm = $this->pdo->query($sql);
  600. if ($stm === false) {
  601. return -1;
  602. }
  603. $res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
  604. return is_numeric($res[0] ?? null) ? (int)$res[0] : 0;
  605. }
  606. }