FeedDAO.php 25 KB

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