The clean & modern RSS server that doesn't give you any crap. https://thearsse.com/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

API.php 71KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511
  1. <?php
  2. /** @license MIT
  3. * Copyright 2017 J. King, Dustin Wilson et al.
  4. * See LICENSE and AUTHORS files for details */
  5. declare(strict_types=1);
  6. namespace JKingWeb\Arsse\REST\TinyTinyRSS;
  7. use JKingWeb\Arsse\Feed;
  8. use JKingWeb\Arsse\Arsse;
  9. use JKingWeb\Arsse\Database;
  10. use JKingWeb\Arsse\User;
  11. use JKingWeb\Arsse\Service;
  12. use JKingWeb\Arsse\Misc\Date;
  13. use JKingWeb\Arsse\Misc\Context;
  14. use JKingWeb\Arsse\Misc\ValueInfo;
  15. use JKingWeb\Arsse\AbstractException;
  16. use JKingWeb\Arsse\ExceptionType;
  17. use JKingWeb\Arsse\Db\ExceptionInput;
  18. use JKingWeb\Arsse\Db\ResultEmpty;
  19. use JKingWeb\Arsse\Feed\Exception as FeedException;
  20. use Psr\Http\Message\ServerRequestInterface;
  21. use Psr\Http\Message\ResponseInterface;
  22. use Zend\Diactoros\Response\JsonResponse as Response;
  23. use Zend\Diactoros\Response\EmptyResponse;
  24. class API extends \JKingWeb\Arsse\REST\AbstractHandler {
  25. const LEVEL = 14; // emulated API level
  26. const VERSION = "17.4"; // emulated TT-RSS version
  27. const LABEL_OFFSET = 1024; // offset below zero at which labels begin, counting down
  28. const LIMIT_ARTICLES = 200; // maximum number of articles returned by getHeadlines
  29. const LIMIT_EXCERPT = 100; // maximum length of excerpts in getHeadlines, counted in grapheme units
  30. // special feeds
  31. const FEED_ARCHIVED = 0;
  32. const FEED_STARRED = -1;
  33. const FEED_PUBLISHED = -2;
  34. const FEED_FRESH = -3;
  35. const FEED_ALL = -4;
  36. const FEED_READ = -6;
  37. // special categories
  38. const CAT_UNCATEGORIZED = 0;
  39. const CAT_SPECIAL = -1;
  40. const CAT_LABELS = -2;
  41. const CAT_NOT_SPECIAL = -3;
  42. const CAT_ALL = -4;
  43. // valid input
  44. const VALID_INPUT = [
  45. 'op' => ValueInfo::T_STRING, // the function ("operation") to perform
  46. 'sid' => ValueInfo::T_STRING, // session ID
  47. 'seq' => ValueInfo::T_INT, // request number from client
  48. 'user' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // user name for `login`
  49. 'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // password for `login` and `subscribeToFeed`
  50. 'include_empty' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include empty items in `getFeedTree` and `getCategories`
  51. 'unread_only' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to exclude items without unread articles in `getCategories` and `getFeeds`
  52. 'enable_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to NOT show subcategories in `getCategories
  53. 'include_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include subcategories in `getFeeds` and the articles thereof in `getHeadlines`
  54. 'caption' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // name for categories, feed, and labels
  55. 'parent_id' => ValueInfo::T_INT, // parent category for `addCategory` and `moveCategory`
  56. 'category_id' => ValueInfo::T_INT, // parent category for `subscribeToFeed` and `moveFeed`, and subject for category-modification functions
  57. 'cat_id' => ValueInfo::T_INT, // parent category for `getFeeds`
  58. 'label_id' => ValueInfo::T_INT, // label ID in label-related functions
  59. 'feed_url' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // URL of feed in `subscribeToFeed`
  60. 'login' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // remote user name in `subscribeToFeed`
  61. 'feed_id' => ValueInfo::T_INT, // feed, label, or category ID for various functions
  62. 'is_cat' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether 'feed_id' refers to a category
  63. 'article_id' => ValueInfo::T_MIXED, // single article ID in `getLabels`; one or more (comma-separated) article IDs in `getArticle`
  64. 'article_ids' => ValueInfo::T_STRING, // one or more (comma-separated) article IDs in `updateArticle` and `setArticleLabel`
  65. 'assign' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to assign or clear (false) a label in `setArticleLabel`
  66. 'limit' => ValueInfo::T_INT, // maximum number of records returned in `getFeeds`, `getHeadlines`, and `getCompactHeadlines`
  67. 'offset' => ValueInfo::T_INT, // number of records to skip in `getFeeds`, for pagination
  68. 'skip' => ValueInfo::T_INT, // number of records to skip in `getHeadlines` and `getCompactHeadlines`, for pagination
  69. 'show_excerpt' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include article excerpts in `getHeadlines`
  70. 'show_content' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include article content in `getHeadlines`
  71. 'include_attachments' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include article enclosures in `getHeadlines`
  72. 'view_mode' => ValueInfo::T_STRING, // various filters for `getHeadlines`
  73. 'since_id' => ValueInfo::T_INT, // cut-off article ID for `getHeadlines` and `getCompactHeadlines; returns only higher article IDs when specified
  74. 'order_by' => ValueInfo::T_STRING, // sort order for `getHeadlines`
  75. 'include_header' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to attach a header to the results of `getHeadlines`
  76. 'search' => ValueInfo::T_STRING, // search string for `getHeadlines` (not yet implemented)
  77. 'field' => ValueInfo::T_INT, // which state to change in `updateArticle`
  78. 'mode' => ValueInfo::T_INT, // whether to set, clear, or toggle the selected state in `updateArticle`
  79. 'data' => ValueInfo::T_STRING, // note text in `updateArticle` if setting a note
  80. ];
  81. // generic error construct
  82. const FATAL_ERR = [
  83. 'seq' => null,
  84. 'status' => 1,
  85. 'content' => ['error' => "MALFORMED_INPUT"],
  86. ];
  87. public function __construct() {
  88. }
  89. public function dispatch(ServerRequestInterface $req): ResponseInterface {
  90. if (!preg_match("<^(?:/(?:index\.php)?)?$>", $req->getRequestTarget())) {
  91. // reject paths other than the index
  92. return new EmptyResponse(404);
  93. }
  94. if ($req->getMethod()=="OPTIONS") {
  95. // respond to OPTIONS rquests; the response is a fib, as we technically accept any type or method
  96. return new EmptyResponse(204, [
  97. 'Allow' => "POST",
  98. 'Accept' => "application/json, text/json",
  99. ]);
  100. }
  101. $data = (string) $req->getBody();
  102. if ($data) {
  103. // only JSON entities are allowed, but Content-Type is ignored, as is request method
  104. $data = @json_decode($data, true);
  105. if (json_last_error() != \JSON_ERROR_NONE || !is_array($data)) {
  106. return new Response(self::FATAL_ERR);
  107. }
  108. try {
  109. // normalize input
  110. try {
  111. $data['seq'] = isset($data['seq']) ? $data['seq'] : 0;
  112. $data = $this->normalizeInput($data, self::VALID_INPUT, "unix");
  113. } catch (ExceptionType $e) {
  114. throw new Exception("INCORRECT_USAGE");
  115. }
  116. if ($req->getAttribute("authenticated", false)) {
  117. // if HTTP authentication was successfully used, set the expected user ID
  118. Arsse::$user->id = $req->getAttribute("authenticatedUser");
  119. } elseif (Arsse::$conf->userHTTPAuthRequired || Arsse::$conf->userPreAuth || $req->getAttribute("authenticationFailed", false)) {
  120. // otherwise if HTTP authentication failed or is required, deny access at the HTTP level
  121. return new EmptyResponse(401);
  122. }
  123. if (strtolower((string) $data['op']) != "login") {
  124. // unless logging in, a session identifier is required
  125. $this->resumeSession((string) $data['sid']);
  126. }
  127. $method = "op".ucfirst($data['op']);
  128. if (!method_exists($this, $method)) {
  129. // TT-RSS operations are case-insensitive by dint of PHP method names being case-insensitive; this will only trigger if the method really doesn't exist
  130. throw new Exception("UNKNOWN_METHOD", ['method' => $data['op']]);
  131. }
  132. return new Response([
  133. 'seq' => $data['seq'],
  134. 'status' => 0,
  135. 'content' => $this->$method($data),
  136. ]);
  137. } catch (Exception $e) {
  138. return new Response([
  139. 'seq' => $data['seq'],
  140. 'status' => 1,
  141. 'content' => $e->getData(),
  142. ]);
  143. } catch (AbstractException $e) {
  144. return new EmptyResponse(500);
  145. }
  146. } else {
  147. // absence of a request body indicates an error
  148. return new Response(self::FATAL_ERR);
  149. }
  150. }
  151. protected function resumeSession(string $id): bool {
  152. // if HTTP authentication was successful and sessions are not enforced, proceed unconditionally
  153. if (isset(Arsse::$user->id) && !Arsse::$conf->userSessionEnforced) {
  154. return true;
  155. }
  156. try {
  157. // verify the supplied session is valid
  158. $s = Arsse::$db->sessionResume($id);
  159. } catch (\JKingWeb\Arsse\User\ExceptionSession $e) {
  160. // if not throw an exception
  161. throw new Exception("NOT_LOGGED_IN");
  162. }
  163. // resume the session (currently only the user name)
  164. Arsse::$user->id = $s['user'];
  165. return true;
  166. }
  167. public function opGetApiLevel(array $data): array {
  168. return ['level' => self::LEVEL];
  169. }
  170. public function opGetVersion(array $data): array {
  171. return [
  172. 'version' => self::VERSION,
  173. 'arsse_version' => Arsse::VERSION,
  174. ];
  175. }
  176. public function opLogin(array $data): array {
  177. $user = $data['user'] ?? "";
  178. $pass = $data['password'] ?? "";
  179. if (!Arsse::$conf->userSessionEnforced && isset(Arsse::$user->id)) {
  180. // if HTTP authentication was previously successful and sessions
  181. // are not enforced, create a session for the HTTP user regardless
  182. // of which user the API call mentions
  183. $id = Arsse::$db->sessionCreate(Arsse::$user->id);
  184. } elseif ((!Arsse::$conf->userPreAuth && (Arsse::$user->auth($user, $pass) || Arsse::$user->auth($user, base64_decode($pass)))) || (Arsse::$conf->userPreAuth && Arsse::$user->id===$user)) {
  185. // otherwise both cleartext and base64 passwords are accepted
  186. // if pre-authentication is in use, just make sure the user names match
  187. $id = Arsse::$db->sessionCreate($user);
  188. } else {
  189. throw new Exception("LOGIN_ERROR");
  190. }
  191. return [
  192. 'session_id' => $id,
  193. 'api_level' => self::LEVEL
  194. ];
  195. }
  196. public function opLogout(array $data): array {
  197. Arsse::$db->sessionDestroy(Arsse::$user->id, $data['sid']);
  198. return ['status' => "OK"];
  199. }
  200. public function opIsLoggedIn(array $data): array {
  201. // session validity is already checked by the dispatcher, so we need only return true
  202. return ['status' => true];
  203. }
  204. public function opGetConfig(array $data): array {
  205. return [
  206. 'icons_dir' => "feed-icons",
  207. 'icons_url' => "feed-icons",
  208. 'daemon_is_running' => Service::hasCheckedIn(),
  209. 'num_feeds' => Arsse::$db->subscriptionCount(Arsse::$user->id),
  210. ];
  211. }
  212. public function opGetUnread(array $data): array {
  213. // simply sum the unread count of each subscription
  214. $out = 0;
  215. foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $sub) {
  216. $out += $sub['unread'];
  217. }
  218. return ['unread' => (string) $out]; // string cast to be consistent with TTRSS
  219. }
  220. public function opGetCounters(array $data): array {
  221. $user = Arsse::$user->id;
  222. $starred = Arsse::$db->articleStarred($user);
  223. $fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H")));
  224. $countAll = 0;
  225. $countSubs = 0;
  226. $feeds = [];
  227. $labels = [];
  228. // do a first pass on categories: add the ID to a lookup table and set the unread counter to zero
  229. $categories = Arsse::$db->folderList($user)->getAll();
  230. $catmap = [];
  231. for ($a = 0; $a < sizeof($categories); $a++) {
  232. $catmap[(int) $categories[$a]['id']] = $a;
  233. $categories[$a]['counter'] = 0;
  234. }
  235. // add the "Uncategorized" and "Labels" virtual categories to the list
  236. $catmap[self::CAT_UNCATEGORIZED] = sizeof($categories);
  237. $categories[] = ['id' => self::CAT_UNCATEGORIZED, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Uncategorized"), 'parent' => 0, 'children' => 0, 'counter' => 0];
  238. $catmap[self::CAT_LABELS] = sizeof($categories);
  239. $categories[] = ['id' => self::CAT_LABELS, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Labels"), 'parent' => 0, 'children' => 0, 'counter' => 0];
  240. // prepare data for each subscription; we also add unread counts for their host categories
  241. foreach (Arsse::$db->subscriptionList($user) as $f) {
  242. // add the feed to the list of feeds
  243. $feeds[] = ['id' => (string) $f['id'], 'updated' => Date::transform($f['updated'], "iso8601", "sql"),'counter' => (int) $f['unread'], 'has_img' => (int) (strlen((string) $f['favicon']) > 0)]; // ID is cast to string for consistency with TTRSS
  244. // add the feed's unread count to the global unread count
  245. $countAll += $f['unread'];
  246. // add the feed's unread count to its category unread count
  247. $categories[$catmap[(int) $f['folder']]]['counter'] += $f['unread'];
  248. // increment the global feed count
  249. $countSubs += 1;
  250. }
  251. // prepare data for each non-empty label
  252. foreach (Arsse::$db->labelList($user, false) as $l) {
  253. $unread = $l['articles'] - $l['read'];
  254. $labels[] = ['id' => $this->labelOut($l['id']), 'counter' => $unread, 'auxcounter' => (int) $l['articles']];
  255. $categories[$catmap[self::CAT_LABELS]]['counter'] += $unread;
  256. }
  257. // do a second pass on categories, summing descendant unread counts for ancestors
  258. $cats = $categories;
  259. $catCounts = [];
  260. while ($cats) {
  261. foreach ($cats as $c) {
  262. if ($c['children']) {
  263. // only act on leaf nodes
  264. continue;
  265. }
  266. if ($c['parent']) {
  267. // if the category has a parent, add its counter to the parent's counter, and decrement the parent's child count
  268. $cats[$catmap[$c['parent']]]['counter'] += $c['counter'];
  269. $cats[$catmap[$c['parent']]]['children'] -= 1;
  270. }
  271. $catCounts[$c['id']] = $c['counter'];
  272. // remove the category from the input list
  273. unset($cats[$catmap[$c['id']]]);
  274. }
  275. }
  276. // do a third pass on categories, building a final category list; this is done so that the original sort order is retained
  277. foreach ($categories as $c) {
  278. $cats[] = ['id' => (int) $c['id'], 'kind' => "cat", 'counter' => $catCounts[$c['id']]];
  279. }
  280. // prepare data for the virtual feeds and other counters
  281. $special = [
  282. ['id' => "global-unread", 'counter' => $countAll], //this should not count archived articles, but we do not have an archive
  283. ['id' => "subscribed-feeds", 'counter' => $countSubs],
  284. ['id' => self::FEED_ARCHIVED, 'counter' => 0, 'auxcounter' => 0], // Archived articles
  285. ['id' => self::FEED_STARRED, 'counter' => (int) $starred['unread'], 'auxcounter' => (int) $starred['total']], // Starred articles
  286. ['id' => self::FEED_PUBLISHED, 'counter' => 0, 'auxcounter' => 0], // Published articles
  287. ['id' => self::FEED_FRESH, 'counter' => $fresh, 'auxcounter' => 0], // Fresh articles
  288. ['id' => self::FEED_ALL, 'counter' => $countAll, 'auxcounter' => 0], // All articles
  289. ];
  290. return array_merge($special, $labels, $feeds, $cats);
  291. }
  292. public function opGetFeedTree(array $data) : array {
  293. $all = $data['include_empty'] ?? false;
  294. $user = Arsse::$user->id;
  295. $tSpecial = [
  296. 'type' => "feed",
  297. 'auxcounter' => 0,
  298. 'error' => "",
  299. 'updated' => "",
  300. ];
  301. $out = [];
  302. // get the lists of categories and feeds
  303. $cats = Arsse::$db->folderList($user, null, true)->getAll();
  304. $subs = Arsse::$db->subscriptionList($user)->getAll();
  305. // start with the special feeds
  306. $out[] = [
  307. 'name' => Arsse::$lang->msg("API.TTRSS.Category.Special"),
  308. 'id' => "CAT:".self::CAT_SPECIAL,
  309. 'bare_id' => self::CAT_SPECIAL,
  310. 'type' => "category",
  311. 'unread' => 0,
  312. 'items' => [
  313. array_merge([ // All articles
  314. 'name' => Arsse::$lang->msg("API.TTRSS.Feed.All"),
  315. 'id' => "FEED:".self::FEED_ALL,
  316. 'bare_id' => self::FEED_ALL,
  317. 'icon' => "images/folder.png",
  318. 'unread' => array_reduce($subs, function($sum, $value) {
  319. return $sum + $value['unread'];
  320. }, 0), // the sum of all feeds' unread is the total unread
  321. ], $tSpecial),
  322. array_merge([ // Fresh articles
  323. 'name' => Arsse::$lang->msg("API.TTRSS.Feed.Fresh"),
  324. 'id' => "FEED:".self::FEED_FRESH,
  325. 'bare_id' => self::FEED_FRESH,
  326. 'icon' => "images/fresh.png",
  327. 'unread' => Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H"))),
  328. ], $tSpecial),
  329. array_merge([ // Starred articles
  330. 'name' => Arsse::$lang->msg("API.TTRSS.Feed.Starred"),
  331. 'id' => "FEED:".self::FEED_STARRED,
  332. 'bare_id' => self::FEED_STARRED,
  333. 'icon' => "images/star.png",
  334. 'unread' => (int) Arsse::$db->articleStarred($user)['unread'],
  335. ], $tSpecial),
  336. array_merge([ // Published articles
  337. 'name' => Arsse::$lang->msg("API.TTRSS.Feed.Published"),
  338. 'id' => "FEED:".self::FEED_PUBLISHED,
  339. 'bare_id' => self::FEED_PUBLISHED,
  340. 'icon' => "images/feed.png",
  341. 'unread' => 0, // TODO: unread count should be populated if the Published feed is ever implemented
  342. ], $tSpecial),
  343. array_merge([ // Archived articles
  344. 'name' => Arsse::$lang->msg("API.TTRSS.Feed.Archived"),
  345. 'id' => "FEED:".self::FEED_ARCHIVED,
  346. 'bare_id' => self::FEED_ARCHIVED,
  347. 'icon' => "images/archive.png",
  348. 'unread' => 0, // Article archiving is not exposed by the API, so this is always zero
  349. ], $tSpecial),
  350. array_merge([ // Recently read
  351. 'name' => Arsse::$lang->msg("API.TTRSS.Feed.Read"),
  352. 'id' => "FEED:".self::FEED_READ,
  353. 'bare_id' => self::FEED_READ,
  354. 'icon' => "images/time.png",
  355. 'unread' => 0, // this is by definition zero; unread articles do not appear in this feed
  356. ], $tSpecial),
  357. ],
  358. ];
  359. // next prepare labels
  360. $items = [];
  361. $unread = 0;
  362. // add each label to a holding list (NOTE: the 'include_empty' parameter does not affect whether labels with zero total articles are shown: all labels are always shown)
  363. foreach (Arsse::$db->labelList($user, true) as $l) {
  364. $items[] = [
  365. 'name' => $l['name'],
  366. 'id' => "FEED:".$this->labelOut($l['id']),
  367. 'bare_id' => $this->labelOut($l['id']),
  368. 'unread' => 0,
  369. 'icon' => "images/label.png",
  370. 'type' => "feed",
  371. 'auxcounter' => 0,
  372. 'error' => "",
  373. 'updated' => "",
  374. 'fg_color' => "",
  375. 'bg_color' => "",
  376. ];
  377. $unread += ($l['articles'] - $l['read']);
  378. }
  379. // if there are labels, all the label category,
  380. if ($items) {
  381. $out[] = [
  382. 'name' => Arsse::$lang->msg("API.TTRSS.Category.Labels"),
  383. 'id' => "CAT:".self::CAT_LABELS,
  384. 'bare_id' => self::CAT_LABELS,
  385. 'type' => "category",
  386. 'unread' => $unread,
  387. 'items' => $items,
  388. ];
  389. }
  390. // get the lists of categories and feeds
  391. $cats = Arsse::$db->folderList($user, null, true)->getAll();
  392. $subs = Arsse::$db->subscriptionList($user)->getAll();
  393. // process all the top-level categories; their contents are gathered recursively in another function
  394. $items = $this->enumerateCategories($cats, $subs, null, $all);
  395. $out = array_merge($out, $items['list']);
  396. // process uncategorized feeds; exclude the "Uncategorized" category if there are no orphan feeds and we're not displaying empties
  397. $items = $this->enumerateFeeds($subs, null);
  398. if ($items || !$all) {
  399. $out[] = [
  400. 'name' => Arsse::$lang->msg("API.TTRSS.Category.Uncategorized"),
  401. 'id' => "CAT:".self::CAT_UNCATEGORIZED,
  402. 'bare_id' => self::CAT_UNCATEGORIZED,
  403. 'type' => "category",
  404. 'auxcounter' => 0,
  405. 'unread' => 0,
  406. 'child_unread' => 0,
  407. 'checkbox' => false,
  408. 'parent_id' => null,
  409. 'param' => Arsse::$lang->msg("API.TTRSS.FeedCount", sizeof($items)),
  410. 'items' => $items,
  411. ];
  412. }
  413. // return the result wrapped in some boilerplate
  414. return ['categories' => ['identifier' => "id", 'label' => "name", 'items' => $out]];
  415. }
  416. protected function enumerateFeeds(array $subs, $parent = null): array {
  417. $out = [];
  418. foreach ($subs as $s) {
  419. if ($s['folder'] != $parent) {
  420. continue;
  421. }
  422. $out[] = [
  423. 'name' => $s['title'],
  424. 'id' => "FEED:".$s['id'],
  425. 'bare_id' => (int) $s['id'],
  426. 'icon' => $s['favicon'] ? "feed-icons/".$s['id'].".ico" : false,
  427. 'error' => (string) $s['err_msg'],
  428. 'param' => Date::transform($s['updated'], "iso8601", "sql"),
  429. 'unread' => 0,
  430. 'auxcounter' => 0,
  431. 'checkbox' => false,
  432. // NOTE: feeds don't have a type property (even though both labels and special feeds do); don't ask me why
  433. ];
  434. }
  435. return $out;
  436. }
  437. protected function enumerateCategories(array $cats, array $subs, $parent = null, bool $all = false): array {
  438. $out = [];
  439. $feedTotal = 0;
  440. foreach ($cats as $c) {
  441. if ($c['parent'] != $parent || (!$all && !($c['children'] + $c['feeds']))) {
  442. // if the category is the wrong level, or if it's empty and we're not including empties, skip it
  443. continue;
  444. }
  445. $children = $c['children'] ? $this->enumerateCategories($cats, $subs, $c['id'], $all) : ['list' => [], 'feeds' => 0];
  446. $feeds = $c['feeds'] ? $this->enumerateFeeds($subs, $c['id']) : [];
  447. $count = sizeof($feeds) + $children['feeds'];
  448. $out[] = [
  449. 'name' => $c['name'],
  450. 'id' => "CAT:".$c['id'],
  451. 'bare_id' => (int) $c['id'],
  452. 'parent_id' => (int) $c['parent'] ?: null, // top-level categories are not supposed to have this property; we deviated and have the property set to null because it's simpler that way
  453. 'type' => "category",
  454. 'auxcounter' => 0,
  455. 'unread' => 0,
  456. 'child_unread' => 0,
  457. 'checkbox' => false,
  458. 'param' => Arsse::$lang->msg("API.TTRSS.FeedCount", $count),
  459. 'items' => array_merge($children['list'], $feeds),
  460. ];
  461. $feedTotal += $count;
  462. }
  463. return ['list' => $out, 'feeds' => $feedTotal];
  464. }
  465. public function opGetCategories(array $data): array {
  466. // normalize input
  467. $all = $data['include_empty'] ?? false;
  468. $read = !($data['unread_only'] ?? false);
  469. $deep = !($data['enable_nested'] ?? false);
  470. $user = Arsse::$user->id;
  471. // for each category, add the ID to a lookup table, set the number of unread to zero, and assign an increasing order index
  472. $cats = Arsse::$db->folderList($user, null, $deep)->getAll();
  473. $map = [];
  474. for ($a = 0; $a < sizeof($cats); $a++) {
  475. $cats[$a]['id'] = (string) $cats[$a]['id']; // real categories have IDs as strings in TTRSS
  476. $map[$cats[$a]['id']] = $a;
  477. $cats[$a]['unread'] = 0;
  478. $cats[$a]['order'] = $a + 1;
  479. }
  480. // add the "Uncategorized", "Special", and "Labels" virtual categories to the list
  481. $map[self::CAT_UNCATEGORIZED] = sizeof($cats);
  482. $cats[] = ['id' => self::CAT_UNCATEGORIZED, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Uncategorized"), 'children' => 0, 'unread' => 0, 'feeds' => 0];
  483. $map[self::CAT_SPECIAL] = sizeof($cats);
  484. $cats[] = ['id' => self::CAT_SPECIAL, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Special"), 'children' => 0, 'unread' => 0, 'feeds' => 6];
  485. $map[self::CAT_LABELS] = sizeof($cats);
  486. $cats[] = ['id' => self::CAT_LABELS, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Labels"), 'children' => 0, 'unread' => 0, 'feeds' => 0];
  487. // for each subscription, add the unread count to its category, and increment the category's feed count
  488. $subs = Arsse::$db->subscriptionList($user);
  489. foreach ($subs as $sub) {
  490. // note we use top_folder if we're in "nested" mode
  491. $f = $map[(int) ($deep ? $sub['folder'] : $sub['top_folder'])];
  492. $cats[$f]['unread'] += $sub['unread'];
  493. if (!$cats[$f]['id']) {
  494. $cats[$f]['feeds'] += 1;
  495. }
  496. }
  497. // for each label, add the unread count to the labels category, and increment the labels category's feed count
  498. $labels = Arsse::$db->labelList($user);
  499. $f = $map[self::CAT_LABELS];
  500. foreach ($labels as $label) {
  501. $cats[$f]['unread'] += $label['articles'] - $label['read'];
  502. $cats[$f]['feeds'] += 1;
  503. }
  504. // get the unread counts for the special feeds
  505. // FIXME: this is pretty inefficient
  506. $f = $map[self::CAT_SPECIAL];
  507. $cats[$f]['unread'] += Arsse::$db->articleStarred($user)['unread']; // starred
  508. $cats[$f]['unread'] += Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H"))); // fresh
  509. if (!$read) {
  510. // if we're only including unread entries, remove any categories with zero unread items (this will by definition also exclude empties)
  511. $count = sizeof($cats);
  512. for ($a = 0; $a < $count; $a++) {
  513. if (!$cats[$a]['unread']) {
  514. unset($cats[$a]);
  515. }
  516. }
  517. $cats = array_values($cats);
  518. } elseif (!$all) {
  519. // otherwise if we're not including empty entries, remove categories with no children and no feeds
  520. $count = sizeof($cats);
  521. for ($a = 0; $a < $count; $a++) {
  522. if (($cats[$a]['children'] + $cats[$a]['feeds']) < 1) {
  523. unset($cats[$a]);
  524. }
  525. }
  526. $cats = array_values($cats);
  527. }
  528. // transform the result and return
  529. $out = [];
  530. for ($a = 0; $a < sizeof($cats); $a++) {
  531. if ($cats[$a]['id']==-2) {
  532. // the Labels category has its unread count as a string in TTRSS (don't ask me why)
  533. settype($cats[$a]['unread'], "string");
  534. }
  535. $out[] = $this->fieldMapNames($cats[$a], [
  536. 'id' => "id",
  537. 'title' => "name",
  538. 'unread' => "unread",
  539. 'order_id' => "order",
  540. ]);
  541. }
  542. return $out;
  543. }
  544. public function opAddCategory(array $data) {
  545. $in = [
  546. 'name' => $data['caption'],
  547. 'parent' => $data['parent_id'],
  548. ];
  549. try {
  550. return (string) Arsse::$db->folderAdd(Arsse::$user->id, $in); // output is a string in TTRSS
  551. } catch (ExceptionInput $e) {
  552. switch ($e->getCode()) {
  553. case 10236: // folder already exists
  554. // retrieve the ID of the existing folder; duplicating a folder silently returns the existing one
  555. $folders = Arsse::$db->folderList(Arsse::$user->id, $in['parent'], false);
  556. foreach ($folders as $folder) {
  557. if ($folder['name']==$in['name']) {
  558. return (string) ((int) $folder['id']); // output is a string in TTRSS
  559. }
  560. }
  561. return false; // @codeCoverageIgnore
  562. case 10235: // parent folder does not exist; this returns false as an ID
  563. return false;
  564. default: // other errors related to input
  565. throw new Exception("INCORRECT_USAGE");
  566. }
  567. }
  568. }
  569. public function opRemoveCategory(array $data) {
  570. if (!ValueInfo::id($data['category_id'])) {
  571. // if the folder is invalid, throw an error
  572. throw new Exception("INCORRECT_USAGE");
  573. }
  574. try {
  575. // attempt to remove the folder
  576. Arsse::$db->folderRemove(Arsse::$user->id, (int) $data['category_id']);
  577. } catch (ExceptionInput $e) {
  578. // ignore all errors
  579. }
  580. return null;
  581. }
  582. public function opMoveCategory(array $data) {
  583. if (!ValueInfo::id($data['category_id']) || !ValueInfo::id($data['parent_id'], true)) {
  584. // if the folder or parent is invalid, throw an error
  585. throw new Exception("INCORRECT_USAGE");
  586. }
  587. $in = [
  588. 'parent' => (int) $data['parent_id'],
  589. ];
  590. try {
  591. // try to move the folder
  592. Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $data['category_id'], $in);
  593. } catch (ExceptionInput $e) {
  594. // ignore all errors
  595. }
  596. return null;
  597. }
  598. public function opRenameCategory(array $data) {
  599. $info = ValueInfo::str($data['caption']);
  600. if (!ValueInfo::id($data['category_id']) || !($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) {
  601. // if the folder or its new name are invalid, throw an error
  602. throw new Exception("INCORRECT_USAGE");
  603. }
  604. $in = [
  605. 'name' => $data['caption'],
  606. ];
  607. try {
  608. // try to rename the folder
  609. Arsse::$db->folderPropertiesSet(Arsse::$user->id, $data['category_id'], $in);
  610. } catch (ExceptionInput $e) {
  611. // ignore all errors
  612. }
  613. return null;
  614. }
  615. public function opGetFeeds(array $data): array {
  616. $user = Arsse::$user->id;
  617. // normalize input
  618. $cat = $data['cat_id'] ?? 0;
  619. $unread = $data['unread_only'] ?? false;
  620. $limit = $data['limit'] ?? 0;
  621. $offset = $data['offset'] ?? 0;
  622. $nested = $data['include_nested'] ?? false;
  623. // if a special category was selected, nesting does not apply
  624. if (!ValueInfo::id($cat)) {
  625. $nested = false;
  626. // if the All, Special, or Labels category was selected, pagination also does not apply
  627. if (in_array($cat, [self::CAT_ALL, self::CAT_SPECIAL, self::CAT_LABELS])) {
  628. $limit = 0;
  629. $offset = 0;
  630. }
  631. }
  632. // retrieve or build the list of relevant feeds
  633. $out = [];
  634. $subs = [];
  635. $count = 0;
  636. // if the category is the special Labels category or the special All category (which includes labels), add labels to the list
  637. if ($cat==self::CAT_ALL || $cat==self::CAT_LABELS) {
  638. // NOTE: unused labels are not included
  639. foreach (Arsse::$db->labelList($user, false) as $l) {
  640. if ($unread && !$l['unread']) {
  641. continue;
  642. }
  643. $out[] = [
  644. 'id' => $this->labelOut($l['id']),
  645. 'title' => $l['name'],
  646. 'unread' => (string) $l['unread'], // the unread count of labels is output as a string in TTRSS
  647. 'cat_id' => self::CAT_LABELS,
  648. ];
  649. }
  650. }
  651. // if the category is the special Special (!) category or the special All category (which includes "special" feeds), add those feeds to the list
  652. if ($cat==self::CAT_ALL || $cat==self::CAT_SPECIAL) {
  653. // gather some statistics
  654. $starred = Arsse::$db->articleStarred($user)['unread'];
  655. $fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H")));
  656. $global = Arsse::$db->articleCount($user, (new Context)->unread(true));
  657. $published = 0; // TODO: if the Published feed is implemented, the getFeeds method needs to be adjusted accordingly
  658. $archived = 0; // the archived feed is non-functional in the TT-RSS protocol itself
  659. // build the list; exclude anything with zero unread if requested
  660. if (!$unread || $starred) {
  661. $out[] = [
  662. 'id' => self::FEED_STARRED,
  663. 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Starred"),
  664. 'unread' => (string) $starred, // output is a string in TTRSS
  665. 'cat_id' => self::CAT_SPECIAL,
  666. ];
  667. }
  668. if (!$unread || $published) {
  669. $out[] = [
  670. 'id' => self::FEED_PUBLISHED,
  671. 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Published"),
  672. 'unread' => (string) $published, // output is a string in TTRSS
  673. 'cat_id' => self::CAT_SPECIAL,
  674. ];
  675. }
  676. if (!$unread || $fresh) {
  677. $out[] = [
  678. 'id' => self::FEED_FRESH,
  679. 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Fresh"),
  680. 'unread' => (string) $fresh, // output is a string in TTRSS
  681. 'cat_id' => self::CAT_SPECIAL,
  682. ];
  683. }
  684. if (!$unread || $global) {
  685. $out[] = [
  686. 'id' => self::FEED_ALL,
  687. 'title' => Arsse::$lang->msg("API.TTRSS.Feed.All"),
  688. 'unread' => (string) $global, // output is a string in TTRSS
  689. 'cat_id' => self::CAT_SPECIAL,
  690. ];
  691. }
  692. if (!$unread) {
  693. $out[] = [
  694. 'id' => self::FEED_READ,
  695. 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Read"),
  696. 'unread' => 0, // zero by definition; this one is -NOT- a string in TTRSS
  697. 'cat_id' => self::CAT_SPECIAL,
  698. ];
  699. }
  700. if (!$unread || $archived) {
  701. $out[] = [
  702. 'id' => self::FEED_ARCHIVED,
  703. 'title' => Arsse::$lang->msg("API.TTRSS.Feed.Archived"),
  704. 'unread' => (string) $archived, // output is a string in TTRSS
  705. 'cat_id' => self::CAT_SPECIAL,
  706. ];
  707. }
  708. }
  709. // categories and real feeds have a sequential order index; we don't store this, so we just increment with each entry from here
  710. $order = 0;
  711. // if a "nested" list was requested, append the category's child categories to the putput
  712. if ($nested) {
  713. try {
  714. // NOTE: the list is a flat one: it includes children, but not other descendents
  715. foreach (Arsse::$db->folderList($user, $cat, false) as $c) {
  716. // get the number of unread for the category and its descendents; those with zero unread are excluded in "unread-only" mode
  717. $count = Arsse::$db->articleCount($user, (new Context)->unread(true)->folder((int) $c['id']));
  718. if (!$unread || $count) {
  719. $out[] = [
  720. 'id' => (int) $c['id'],
  721. 'title' => $c['name'],
  722. 'unread' => (int) $count,
  723. 'is_cat' => true,
  724. 'order_id' => ++$order,
  725. ];
  726. }
  727. }
  728. } catch (ExceptionInput $e) {
  729. // in case of errors (because the category does not exist) return the list so far (which should be empty)
  730. return $out;
  731. }
  732. }
  733. try {
  734. if ($cat==self::CAT_NOT_SPECIAL || $cat==self::CAT_ALL) {
  735. // if the "All" or "Not Special" categories were selected this returns all subscription, to any depth
  736. $subs = Arsse::$db->subscriptionList($user, null, true);
  737. } elseif ($cat==self::CAT_UNCATEGORIZED) {
  738. // the "Uncategorized" special category returns subscriptions in the root, without going deeper
  739. $subs = Arsse::$db->subscriptionList($user, null, false);
  740. } else {
  741. // other categories return their subscriptions, without going deeper
  742. $subs = Arsse::$db->subscriptionList($user, $cat, false);
  743. }
  744. } catch (ExceptionInput $e) {
  745. // in case of errors (invalid category), return what we have so far
  746. return $out;
  747. }
  748. // append subscriptions to the output
  749. $order = 0;
  750. $count = 0;
  751. foreach ($subs as $s) {
  752. $order++;
  753. if ($unread && !$s['unread']) {
  754. // ignore any subscriptions with zero unread in "unread-only" mode
  755. continue;
  756. } elseif ($offset > 0) {
  757. // skip as many subscriptions as necessary to remove any requested offset
  758. $offset--;
  759. continue;
  760. } elseif ($limit && $count >= $limit) {
  761. // if we've reached the requested limit, stop
  762. // NOTE: TT-RSS blindly accepts negative limits and returns an empty array
  763. break;
  764. }
  765. // otherwise, append the subscription
  766. $out[] = [
  767. 'id' => (int) $s['id'],
  768. 'title' => $s['title'],
  769. 'unread' => (int) $s['unread'],
  770. 'cat_id' => (int) $s['folder'],
  771. 'feed_url' => $s['url'],
  772. 'has_icon' => (bool) $s['favicon'],
  773. 'last_updated' => (int) Date::transform($s['updated'], "unix", "sql"),
  774. 'order_id' => $order,
  775. ];
  776. $count++;
  777. }
  778. return $out;
  779. }
  780. protected function feedError(FeedException $e): array {
  781. // N.B.: we don't return code 4 (multiple feeds discovered); we simply pick the first feed discovered
  782. switch ($e->getCode()) {
  783. case 10502: // invalid URL
  784. return ['code' => 2, 'message' => $e->getMessage()];
  785. case 10521: // no feeds discovered
  786. return ['code' => 3, 'message' => $e->getMessage()];
  787. case 10511:
  788. case 10512:
  789. case 10522: // malformed data
  790. return ['code' => 6, 'message' => $e->getMessage()];
  791. default: // unable to download
  792. return ['code' => 5, 'message' => $e->getMessage()];
  793. }
  794. }
  795. public function opSubscribeToFeed(array $data): array {
  796. if (!$data['feed_url'] || !ValueInfo::id($data['category_id'], true)) {
  797. // if the feed URL or the category ID is invalid, throw an error
  798. throw new Exception("INCORRECT_USAGE");
  799. }
  800. $url = (string) $data['feed_url'];
  801. $folder = (int) $data['category_id'];
  802. $fetchUser = (string) $data['login'];
  803. $fetchPassword = (string) $data['password'];
  804. // check to make sure the requested folder exists before doing anything else, if one is specified
  805. if ($folder) {
  806. try {
  807. Arsse::$db->folderPropertiesGet(Arsse::$user->id, $folder);
  808. } catch (ExceptionInput $e) {
  809. // folder does not exist: TT-RSS is a bit weird in this case and returns a feed ID of 0. It checks the feed first, but we do not
  810. return ['code' => 1, 'feed_id' => 0];
  811. }
  812. }
  813. try {
  814. $id = Arsse::$db->subscriptionAdd(Arsse::$user->id, $url, $fetchUser, $fetchPassword);
  815. } catch (ExceptionInput $e) {
  816. // subscription already exists; retrieve the existing ID and return that with the correct code
  817. for ($triedDiscovery = 0; $triedDiscovery <= 1; $triedDiscovery++) {
  818. $subs = Arsse::$db->subscriptionList(Arsse::$user->id);
  819. $id = false;
  820. foreach ($subs as $sub) {
  821. if ($sub['url']===$url) {
  822. $id = (int) $sub['id'];
  823. break;
  824. }
  825. }
  826. if ($id) {
  827. break;
  828. } elseif (!$triedDiscovery) {
  829. // if we didn't find the ID we perform feed discovery for the next iteration; this is pretty messy: discovery ends up being done twice because it was already done in $db->subscriptionAdd()
  830. try {
  831. $url = Feed::discover($url, $fetchUser, $fetchPassword);
  832. } catch (FeedException $e) {
  833. // feed errors (handled above)
  834. return $this->feedError($e);
  835. }
  836. }
  837. }
  838. return ['code' => 0, 'feed_id' => $id];
  839. } catch (FeedException $e) {
  840. // feed errors (handled above)
  841. return $this->feedError($e);
  842. }
  843. // if all went well, move the new subscription to the requested folder (if one was requested)
  844. try {
  845. Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, ['folder' => $folder]);
  846. } catch (ExceptionInput $e) {
  847. // ignore errors
  848. }
  849. return ['code' => 1, 'feed_id' => $id];
  850. }
  851. public function opUnsubscribeFeed(array $data): array {
  852. try {
  853. // attempt to remove the feed
  854. Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $data['feed_id']);
  855. } catch (ExceptionInput $e) {
  856. throw new Exception("FEED_NOT_FOUND");
  857. }
  858. return ['status' => "OK"];
  859. }
  860. public function opMoveFeed(array $data) {
  861. if (!ValueInfo::id($data['feed_id']) || !isset($data['category_id']) || !ValueInfo::id($data['category_id'], true)) {
  862. // if the feed or folder is invalid, throw an error
  863. throw new Exception("INCORRECT_USAGE");
  864. }
  865. $in = [
  866. 'folder' => $data['category_id'],
  867. ];
  868. try {
  869. // try to move the feed
  870. Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $data['feed_id'], $in);
  871. } catch (ExceptionInput $e) {
  872. // ignore all errors
  873. }
  874. return null;
  875. }
  876. public function opRenameFeed(array $data) {
  877. $info = ValueInfo::str($data['caption']);
  878. if (!ValueInfo::id($data['feed_id']) || !($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) {
  879. // if the feed ID or name is invalid, throw an error
  880. throw new Exception("INCORRECT_USAGE");
  881. }
  882. $in = [
  883. 'title' => $data['caption'],
  884. ];
  885. try {
  886. // try to rename the feed
  887. Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $data['feed_id'], $in);
  888. } catch (ExceptionInput $e) {
  889. // ignore all errors
  890. }
  891. return null;
  892. }
  893. public function opUpdateFeed(array $data): array {
  894. if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id'])) {
  895. // if the feed is invalid, throw an error
  896. throw new Exception("INCORRECT_USAGE");
  897. }
  898. try {
  899. Arsse::$db->feedUpdate(Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, $data['feed_id'])['feed']);
  900. } catch (ExceptionInput $e) {
  901. throw new Exception("FEED_NOT_FOUND");
  902. }
  903. return ['status' => "OK"];
  904. }
  905. protected function labelIn($id, bool $throw = true): int {
  906. if (!(ValueInfo::int($id) & ValueInfo::NEG) || $id > (-1 - self::LABEL_OFFSET)) {
  907. if ($throw) {
  908. throw new Exception("INCORRECT_USAGE");
  909. } else {
  910. return 0;
  911. }
  912. }
  913. return (abs($id) - self::LABEL_OFFSET);
  914. }
  915. protected function labelOut($id): int {
  916. return ((int) $id * -1 - self::LABEL_OFFSET);
  917. }
  918. public function opGetLabels(array $data): array {
  919. // this function doesn't complain about invalid article IDs
  920. $article = ValueInfo::id($data['article_id']) ? $data['article_id'] : 0;
  921. try {
  922. $list = $article ? Arsse::$db->articleLabelsGet(Arsse::$user->id, $article) : [];
  923. } catch (ExceptionInput $e) {
  924. $list = [];
  925. }
  926. $out = [];
  927. foreach (Arsse::$db->labelList(Arsse::$user->id) as $l) {
  928. $out[] = [
  929. 'id' => $this->labelOut($l['id']),
  930. 'caption' => $l['name'],
  931. 'fg_color' => "",
  932. 'bg_color' => "",
  933. 'checked' => in_array($l['id'], $list),
  934. ];
  935. }
  936. return $out;
  937. }
  938. public function opAddLabel(array $data) {
  939. $in = [
  940. 'name' => (string) $data['caption'],
  941. ];
  942. try {
  943. return $this->labelOut(Arsse::$db->labelAdd(Arsse::$user->id, $in));
  944. } catch (ExceptionInput $e) {
  945. switch ($e->getCode()) {
  946. case 10236: // label already exists
  947. // retrieve the ID of the existing label; duplicating a label silently returns the existing one
  948. return $this->labelOut(Arsse::$db->labelPropertiesGet(Arsse::$user->id, $in['name'], true)['id']);
  949. default: // other errors related to input
  950. throw new Exception("INCORRECT_USAGE");
  951. }
  952. }
  953. }
  954. public function opRemoveLabel(array $data) {
  955. // normalize the label ID; missing or invalid IDs are rejected
  956. $id = $this->labelIn($data['label_id']);
  957. try {
  958. // attempt to remove the label
  959. Arsse::$db->labelRemove(Arsse::$user->id, $id);
  960. } catch (ExceptionInput $e) {
  961. // ignore all errors
  962. }
  963. return null;
  964. }
  965. public function opRenameLabel(array $data) {
  966. // normalize input; missing or invalid IDs are rejected
  967. $id = $this->labelIn($data['label_id']);
  968. $name = (string) $data['caption'];
  969. try {
  970. // try to rename the folder
  971. Arsse::$db->labelPropertiesSet(Arsse::$user->id, $id, ['name' => $name]);
  972. } catch (ExceptionInput $e) {
  973. if ($e->getCode()==10237) {
  974. // if the supplied ID was invalid, report an error; other errors are to be ignored
  975. throw new Exception("INCORRECT_USAGE");
  976. }
  977. }
  978. return null;
  979. }
  980. public function opSetArticleLabel(array $data): array {
  981. $label = $this->labelIn($data['label_id']);
  982. $articles = explode(",", (string) $data['article_ids']);
  983. $assign = $data['assign'] ?? false;
  984. $out = 0;
  985. $in = array_chunk($articles, 50);
  986. for ($a = 0; $a < sizeof($in); $a++) {
  987. // initialize the matching context
  988. $c = new Context;
  989. $c->articles($in[$a]);
  990. try {
  991. $out += Arsse::$db->labelArticlesSet(Arsse::$user->id, $label, $c, !$assign);
  992. } catch (ExceptionInput $e) {
  993. }
  994. }
  995. return ['status' => "OK", 'updated' => $out];
  996. }
  997. public function opCatchUpFeed(array $data): array {
  998. $id = $data['feed_id'] ?? self::FEED_ARCHIVED;
  999. $cat = $data['is_cat'] ?? false;
  1000. $out = ['status' => "OK"];
  1001. // first prepare the context; unsupported contexts simply return early
  1002. $c = new Context;
  1003. if ($cat) { // categories
  1004. switch ($id) {
  1005. case self::CAT_SPECIAL:
  1006. case self::CAT_NOT_SPECIAL:
  1007. case self::CAT_ALL:
  1008. // not valid
  1009. return $out;
  1010. case self::CAT_UNCATEGORIZED:
  1011. // this requires a shallow context since in TTRSS the zero/null folder ("Uncategorized") is apart from the tree rather than at the root
  1012. $c->folderShallow(0);
  1013. break;
  1014. case self::CAT_LABELS:
  1015. $c->labelled(true);
  1016. break;
  1017. default:
  1018. // any actual category
  1019. $c->folder($id);
  1020. break;
  1021. }
  1022. } else { // feeds
  1023. if ($this->labelIn($id, false)) { // labels
  1024. $c->label($this->labelIn($id));
  1025. } else {
  1026. switch ($id) {
  1027. case self::FEED_ARCHIVED:
  1028. // not implemented (also, evidently, not implemented in TTRSS)
  1029. return $out;
  1030. case self::FEED_STARRED:
  1031. $c->starred(true);
  1032. break;
  1033. case self::FEED_PUBLISHED:
  1034. // not implemented
  1035. // TODO: if the Published feed is implemented, the catchup function needs to be modified accordingly
  1036. return $out;
  1037. case self::FEED_FRESH:
  1038. $c->modifiedSince(Date::sub("PT24H"));
  1039. break;
  1040. case self::FEED_ALL:
  1041. // no context needed here
  1042. break;
  1043. case self::FEED_READ:
  1044. // everything in the Recently read feed is, by definition, already read
  1045. return $out;
  1046. default:
  1047. // any actual feed
  1048. $c->subscription($id);
  1049. }
  1050. }
  1051. }
  1052. // perform the marking
  1053. try {
  1054. Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
  1055. } catch (ExceptionInput $e) {
  1056. // ignore all errors
  1057. }
  1058. // return boilerplate output
  1059. return $out;
  1060. }
  1061. public function opUpdateArticle(array $data): array {
  1062. // normalize input
  1063. $articles = array_filter(ValueInfo::normalize(explode(",", (string) $data['article_ids']), ValueInfo::T_INT | ValueInfo::M_ARRAY), [ValueInfo::class, "id"]);
  1064. if (!$articles) {
  1065. // if there are no valid articles this is an error
  1066. throw new Exception("INCORRECT_USAGE");
  1067. }
  1068. $out = 0;
  1069. $tr = Arsse::$db->begin();
  1070. switch ($data['field']) {
  1071. case 0: // starred
  1072. switch ($data['mode']) {
  1073. case 0: // set false
  1074. case 1: // set true
  1075. $out += Arsse::$db->articleMark(Arsse::$user->id, ['starred' => (bool) $data['mode']], (new Context)->articles($articles));
  1076. break;
  1077. case 2: //toggle
  1078. $on = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->starred(true), ["id"])->getAll(), "id");
  1079. $off = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->starred(false), ["id"])->getAll(), "id");
  1080. if ($off) {
  1081. $out += Arsse::$db->articleMark(Arsse::$user->id, ['starred' => true], (new Context)->articles($off));
  1082. }
  1083. if ($on) {
  1084. $out += Arsse::$db->articleMark(Arsse::$user->id, ['starred' => false], (new Context)->articles($on));
  1085. }
  1086. break;
  1087. default:
  1088. throw new Exception("INCORRECT_USAGE");
  1089. }
  1090. break;
  1091. case 1: // published
  1092. switch ($data['mode']) {
  1093. case 0: // set false
  1094. case 1: // set true
  1095. case 2: //toggle
  1096. // TODO: the Published feed is not yet implemeted; once it is the updateArticle operation must be amended accordingly
  1097. break;
  1098. default:
  1099. throw new Exception("INCORRECT_USAGE");
  1100. }
  1101. break;
  1102. case 2: // unread
  1103. // NOTE: we use a "read" flag rather than "unread", so the booleans are swapped
  1104. switch ($data['mode']) {
  1105. case 0: // set false
  1106. case 1: // set true
  1107. $out += Arsse::$db->articleMark(Arsse::$user->id, ['read' => !$data['mode']], (new Context)->articles($articles));
  1108. break;
  1109. case 2: //toggle
  1110. $on = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->unread(true), ["id"])->getAll(), "id");
  1111. $off = array_column(Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)->unread(false), ["id"])->getAll(), "id");
  1112. if ($off) {
  1113. $out += Arsse::$db->articleMark(Arsse::$user->id, ['read' => false], (new Context)->articles($off));
  1114. }
  1115. if ($on) {
  1116. $out += Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], (new Context)->articles($on));
  1117. }
  1118. break;
  1119. default:
  1120. throw new Exception("INCORRECT_USAGE");
  1121. }
  1122. break;
  1123. case 3: // article note
  1124. $out += Arsse::$db->articleMark(Arsse::$user->id, ['note' => (string) $data['data']], (new Context)->articles($articles));
  1125. break;
  1126. default:
  1127. throw new Exception("INCORRECT_USAGE");
  1128. }
  1129. $tr->commit();
  1130. return ['status' => "OK", 'updated' => $out];
  1131. }
  1132. public function opGetArticle(array $data): array {
  1133. // normalize input
  1134. $articles = array_filter(ValueInfo::normalize(explode(",", (string) $data['article_id']), ValueInfo::T_INT | ValueInfo::M_ARRAY), [ValueInfo::class, "id"]);
  1135. if (!$articles) {
  1136. // if there are no valid articles this is an error
  1137. throw new Exception("INCORRECT_USAGE");
  1138. }
  1139. $tr = Arsse::$db->begin();
  1140. // retrieve the list of label names for the user
  1141. $labels = [];
  1142. foreach (Arsse::$db->labelList(Arsse::$user->id, false) as $label) {
  1143. $labels[$label['id']] = $label['name'];
  1144. }
  1145. // retrieve the requested articles
  1146. $out = [];
  1147. $columns = [
  1148. "id",
  1149. "guid",
  1150. "title",
  1151. "url",
  1152. "unread",
  1153. "starred",
  1154. "edited_date",
  1155. "subscription",
  1156. "subscription_title",
  1157. "note",
  1158. "content",
  1159. "media_url",
  1160. "media_type",
  1161. ];
  1162. foreach (Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles), $columns) as $article) {
  1163. $out[] = [
  1164. 'id' => (string) $article['id'], // string cast to be consistent with TTRSS
  1165. 'guid' => $article['guid'] ? "SHA256:".$article['guid'] : null,
  1166. 'title' => $article['title'],
  1167. 'link' => $article['url'],
  1168. 'labels' => $this->articleLabelList($labels, $article['id']),
  1169. 'unread' => (bool) $article['unread'],
  1170. 'marked' => (bool) $article['starred'],
  1171. 'published' => false, // TODO: if the Published feed is implemented, the getArticle operation should be amended accordingly
  1172. 'comments' => "", // FIXME: What is this?
  1173. 'author' => $article['author'],
  1174. 'updated' => Date::transform($article['edited_date'], "unix", "sql"),
  1175. 'feed_id' => (string) $article['subscription'], // string cast to be consistent with TTRSS
  1176. 'feed_title' => $article['subscription_title'],
  1177. 'attachments' => $article['media_url'] ? [[
  1178. 'id' => (string) 0, // string cast to be consistent with TTRSS; nonsense ID because we don't use them for enclosures
  1179. 'content_url' => $article['media_url'],
  1180. 'content_type' => $article['media_type'],
  1181. 'title' => "",
  1182. 'duration' => "",
  1183. 'width' => "",
  1184. 'height' => "",
  1185. 'post_id' => (string) $article['id'], // string cast to be consistent with TTRSS
  1186. ]] : [], // TODO: We need to support multiple enclosures
  1187. 'score' => 0, // score is not implemented as it is not modifiable from the TTRSS API
  1188. 'note' => strlen((string) $article['note']) ? $article['note'] : null,
  1189. 'lang' => "", // FIXME: picoFeed should be able to retrieve this information
  1190. 'content' => $article['content'],
  1191. ];
  1192. }
  1193. return $out;
  1194. }
  1195. protected function articleLabelList(array $labels, $id): array {
  1196. $out = [];
  1197. if (!$labels) {
  1198. return $out;
  1199. }
  1200. foreach (Arsse::$db->articleLabelsGet(Arsse::$user->id, (int) $id) as $label) {
  1201. $out[] = [
  1202. $this->labelOut($label), // ID
  1203. $labels[$label], // name
  1204. "", // foreground colour
  1205. "", // background colour
  1206. ];
  1207. }
  1208. return $out;
  1209. }
  1210. public function opGetCompactHeadlines(array $data): array {
  1211. // getCompactHeadlines supports fewer features than getHeadlines
  1212. $data = [
  1213. 'feed_id' => $data['feed_id'],
  1214. 'view_mode' => $data['view_mode'],
  1215. 'since_id' => $data['since_id'],
  1216. 'limit' => $data['limit'],
  1217. 'skip' => $data['skip'],
  1218. ];
  1219. $data = $this->normalizeInput($data, self::VALID_INPUT, "unix");
  1220. // fetch the list of IDs
  1221. $out = [];
  1222. try {
  1223. foreach ($this->fetchArticles($data, ["id"]) as $row) {
  1224. $out[] = ['id' => (int) $row['id']];
  1225. }
  1226. } catch (ExceptionInput $e) {
  1227. // ignore database errors (feeds/categories that don't exist)
  1228. }
  1229. return $out;
  1230. }
  1231. public function opGetHeadlines(array $data): array {
  1232. // normalize input
  1233. $data['limit'] = max(min(!$data['limit'] ? self::LIMIT_ARTICLES : $data['limit'], self::LIMIT_ARTICLES), 0); // at most 200; not specified/zero yields 200; negative values yield no limit
  1234. $tr = Arsse::$db->begin();
  1235. // retrieve the list of label names for the user
  1236. $labels = [];
  1237. foreach (Arsse::$db->labelList(Arsse::$user->id, false) as $label) {
  1238. $labels[$label['id']] = $label['name'];
  1239. }
  1240. // retrieve the requested articles
  1241. $out = [];
  1242. try {
  1243. $columns = [
  1244. "id",
  1245. "guid",
  1246. "title",
  1247. "url",
  1248. "unread",
  1249. "starred",
  1250. "edited_date",
  1251. "published_date",
  1252. "subscription",
  1253. "subscription_title",
  1254. "note",
  1255. ($data['show_content'] || $data['show_excerpt']) ? "content" : "",
  1256. ($data['include_attachments']) ? "media_url": "",
  1257. ($data['include_attachments']) ? "media_type": "",
  1258. ];
  1259. foreach ($this->fetchArticles($data, $columns) as $article) {
  1260. $row = [
  1261. 'id' => (int) $article['id'],
  1262. 'guid' => $article['guid'] ? "SHA256:".$article['guid'] : "",
  1263. 'title' => $article['title'],
  1264. 'link' => $article['url'],
  1265. 'labels' => $this->articleLabelList($labels, $article['id']),
  1266. 'unread' => (bool) $article['unread'],
  1267. 'marked' => (bool) $article['starred'],
  1268. 'published' => false, // TODO: if the Published feed is implemented, the getHeadlines operation should be amended accordingly
  1269. 'author' => $article['author'],
  1270. 'updated' => Date::transform($article['edited_date'], "unix", "sql"),
  1271. 'is_updated' => ($article['published_date'] < $article['edited_date']),
  1272. 'feed_id' => (string) $article['subscription'], // string cast to be consistent with TTRSS
  1273. 'feed_title' => $article['subscription_title'],
  1274. 'score' => 0, // score is not implemented as it is not modifiable from the TTRSS API
  1275. 'note' => strlen((string) $article['note']) ? $article['note'] : null,
  1276. 'lang' => "", // FIXME: picoFeed should be able to retrieve this information
  1277. 'tags' => Arsse::$db->articleCategoriesGet(Arsse::$user->id, $article['id']),
  1278. 'comments_count' => 0,
  1279. 'comments_link' => "",
  1280. 'always_display_attachments' => false,
  1281. ];
  1282. if ($data['show_content']) {
  1283. $row['content'] = $article['content'];
  1284. }
  1285. if ($data['show_excerpt']) {
  1286. // prepare an excerpt from the content
  1287. $text = strip_tags($article['content']); // get rid of all tags; elements with problematic content (e.g. script, style) should already be gone thanks to sanitization
  1288. $text = html_entity_decode($text, \ENT_QUOTES | \ENT_HTML5, "UTF-8");
  1289. $text = trim($text); // trim whitespace at ends
  1290. $text = preg_replace("<\s+>s", " ", $text); // replace runs of whitespace with a single space
  1291. $row['excerpt'] = grapheme_substr($text, 0, self::LIMIT_EXCERPT).(grapheme_strlen($text) > self::LIMIT_EXCERPT ? "…" : ""); // add an ellipsis if the string is longer than N characters
  1292. }
  1293. if ($data['include_attachments']) {
  1294. $row['attachments'] = $article['media_url'] ? [[
  1295. 'id' => (string) 0, // string cast to be consistent with TTRSS; nonsense ID because we don't use them for enclosures
  1296. 'content_url' => $article['media_url'],
  1297. 'content_type' => $article['media_type'],
  1298. 'title' => "",
  1299. 'duration' => "",
  1300. 'width' => "",
  1301. 'height' => "",
  1302. 'post_id' => (string) $article['id'], // string cast to be consistent with TTRSS
  1303. ]] : []; // TODO: We need to support multiple enclosures
  1304. }
  1305. $out[] = $row;
  1306. }
  1307. } catch (ExceptionInput $e) {
  1308. // ignore database errors (feeds/categories that don't exist)
  1309. // ensure that if using a header the database is not needlessly queried again
  1310. $data['skip'] = null;
  1311. }
  1312. if ($data['include_header']) {
  1313. if ($data['skip'] > 0 && $data['order_by'] != "date_reverse") {
  1314. // when paginating the header returns the latest ("first") item ID in the full list; we get this ID here
  1315. $data['skip'] = 0;
  1316. $data['limit'] = 1;
  1317. $firstID = ($this->fetchArticles($data, ["id"])->getRow() ?? ['id' => 0])['id'];
  1318. } elseif ($data['order_by']=="date_reverse") {
  1319. // the "date_reverse" sort order doesn't get a first ID because it's meaningless for ascending-order pagination (pages doesn't go stale)
  1320. $firstID = 0;
  1321. } else {
  1322. // otherwise just use the ID of the first item in the list we've already computed
  1323. $firstID = ($out) ? $out[0]['id'] : 0;
  1324. }
  1325. // wrap the output with (but after) the header
  1326. $out = [
  1327. [
  1328. 'id' => (int) $data['feed_id'],
  1329. 'is_cat' => $data['is_cat'] ?? false,
  1330. 'first_id' => (int) $firstID,
  1331. ],
  1332. $out,
  1333. ];
  1334. }
  1335. return $out;
  1336. }
  1337. protected function fetchArticles(array $data, array $fields): \JKingWeb\Arsse\Db\Result {
  1338. // normalize input
  1339. if (is_null($data['feed_id'])) {
  1340. throw new Exception("INCORRECT_USAGE");
  1341. }
  1342. $id = $data['feed_id'];
  1343. $cat = $data['is_cat'] ?? false;
  1344. $shallow = !($data['include_nested'] ?? false);
  1345. $viewMode = in_array($data['view_mode'], ["all_articles", "adaptive", "unread", "marked", "has_note", "published"]) ? $data['view_mode'] : "all_articles";
  1346. // prepare the context; unsupported, invalid, or inherently empty contexts return synthetic empty result sets
  1347. $c = new Context;
  1348. $tr = Arsse::$db->begin();
  1349. // start with the feed or category ID
  1350. if ($cat) { // categories
  1351. switch ($id) {
  1352. case self::CAT_SPECIAL:
  1353. // not valid
  1354. return new ResultEmpty;
  1355. case self::CAT_NOT_SPECIAL:
  1356. case self::CAT_ALL:
  1357. // no context needed here
  1358. break;
  1359. case self::CAT_UNCATEGORIZED:
  1360. // this requires a shallow context since in TTRSS the zero/null folder ("Uncategorized") is apart from the tree rather than at the root
  1361. $c->folderShallow(0);
  1362. break;
  1363. case self::CAT_LABELS:
  1364. $c->labelled(true);
  1365. break;
  1366. default:
  1367. // any actual category
  1368. if ($shallow) {
  1369. $c->folderShallow($id);
  1370. } else {
  1371. $c->folder($id);
  1372. }
  1373. break;
  1374. }
  1375. } else { // feeds
  1376. if ($this->labelIn($id, false)) { // labels
  1377. $c->label($this->labelIn($id));
  1378. } else {
  1379. switch ($id) {
  1380. case self::FEED_ARCHIVED:
  1381. // not implemented
  1382. return new ResultEmpty;
  1383. case self::FEED_STARRED:
  1384. $c->starred(true);
  1385. break;
  1386. case self::FEED_PUBLISHED:
  1387. // not implemented
  1388. // TODO: if the Published feed is implemented, the headline function needs to be modified accordingly
  1389. return new ResultEmpty;
  1390. case self::FEED_FRESH:
  1391. $c->modifiedSince(Date::sub("PT24H"))->unread(true);
  1392. break;
  1393. case self::FEED_ALL:
  1394. // no context needed here
  1395. break;
  1396. case self::FEED_READ:
  1397. $c->markedSince(Date::sub("PT24H"))->unread(false); // FIXME: this selects any recently touched article which is read, not necessarily a recently read one
  1398. break;
  1399. default:
  1400. // any actual feed
  1401. $c->subscription($id);
  1402. break;
  1403. }
  1404. }
  1405. }
  1406. // next handle the view mode
  1407. switch ($viewMode) {
  1408. case "all_articles":
  1409. // no context needed here
  1410. break;
  1411. case "adaptive":
  1412. // adaptive means "return only unread unless there are none, in which case return all articles"
  1413. if ($c->unread !== false && Arsse::$db->articleCount(Arsse::$user->id, (clone $c)->unread(true))) {
  1414. $c->unread(true);
  1415. }
  1416. break;
  1417. case "unread":
  1418. if ($c->unread !== false) {
  1419. $c->unread(true);
  1420. } else {
  1421. // unread mode in the "Recently Read" feed is a no-op
  1422. return new ResultEmpty;
  1423. }
  1424. break;
  1425. case "marked":
  1426. $c->starred(true);
  1427. break;
  1428. case "has_note":
  1429. $c->annotated(true);
  1430. break;
  1431. case "published":
  1432. // not implemented
  1433. // TODO: if the Published feed is implemented, the headline function needs to be modified accordingly
  1434. return new ResultEmpty;
  1435. default:
  1436. throw new \JKingWeb\Arsse\Exception("constantUnknown", $viewMode); // @codeCoverageIgnore
  1437. }
  1438. // TODO: implement searching
  1439. // handle sorting
  1440. switch ($data['order_by']) {
  1441. case "date_reverse":
  1442. // sort oldest first
  1443. $c->reverse(false);
  1444. break;
  1445. case "feed_dates":
  1446. // sort newest first
  1447. $c->reverse(true);
  1448. break;
  1449. default:
  1450. // in TT-RSS the default sort order is unusual for some of the special feeds; we do not implement this
  1451. $c->reverse(true);
  1452. break;
  1453. }
  1454. // set the limit and offset
  1455. if ($data['limit'] > 0) {
  1456. $c->limit($data['limit']);
  1457. }
  1458. if ($data['skip'] > 0) {
  1459. $c->offset($data['skip']);
  1460. }
  1461. // set the minimum article ID
  1462. if ($data['since_id'] > 0) {
  1463. $c->oldestArticle($data['since_id'] + 1);
  1464. }
  1465. // return results
  1466. return Arsse::$db->articleList(Arsse::$user->id, $c, $fields);
  1467. }
  1468. }