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.

947 lines
42 KiB

<?php
declare(strict_types=1);
namespace JKingWeb\Arsse\REST\TinyTinyRSS;
use JKingWeb\Arsse\Feed;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Service;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\Context;
use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\ExceptionType;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\REST\Response;
/*
Protocol difference so far:
- Handling of incorrect Content-Type and/or HTTP method is different
- TT-RSS accepts whitespace-only names for categories, labels, and feeds; we do not
- TT-RSS allows two folders to share the same name under the same parent; we do not
- TT-RSS requires the user to choose in the face of multiple found feeds during discovery; we use the first one (picoFeed limitation)
- Session lifetime is much shorter by default
- Categories and feeds will always be sorted alphabetically (the protocol does not allow for clients to re-order)
- The "Archived" virtual feed is non-functional (the protocol does not allow archiving)
- The "Published" virtual feed is non-functional (this will not be implemented in the near term)
- setArticleLabel responds with errors for invalid labels where TT-RSS simply returns a zero result
- The result of setArticleLabel counts only records which actually changed rather than all entries attempted
- Top-level categories in getFeedTree have a 'parent_id' property (set to null); in TT-RSS the property is absent
*/
class API extends \JKingWeb\Arsse\REST\AbstractHandler {
const LEVEL = 14;
const VERSION = "17.4";
const LABEL_OFFSET = 1024;
// special feeds
const FEED_ARCHIVED = 0;
const FEED_STARRED = -1;
const FEED_PUBLISHED = -2;
const FEED_FRESH = -3;
const FEED_ALL = -4;
const FEED_READ = -6;
// special categories
const CAT_UNCATEGORIZED = 0;
const CAT_SPECIAL = -1;
const CAT_LABELS = -2;
const CAT_NOT_SPECIAL = -3;
const CAT_ALL = -4;
// valid input
const VALID_INPUT = [
'op' => ValueInfo::T_STRING,
'sid' => ValueInfo::T_STRING,
'seq' => ValueInfo::T_INT,
'user' => ValueInfo::T_STRING | ValueInfo::M_STRICT,
'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT,
'include_empty' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'unread_only' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'enable_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'caption' => ValueInfo::T_STRING | ValueInfo::M_STRICT,
'parent_id' => ValueInfo::T_INT,
'category_id' => ValueInfo::T_INT,
'feed_url' => ValueInfo::T_STRING | ValueInfo::M_STRICT,
'login' => ValueInfo::T_STRING | ValueInfo::M_STRICT,
'feed_id' => ValueInfo::T_INT,
'article_id' => ValueInfo::T_MIXED, // single integer or comma-separated list in getArticle
'label_id' => ValueInfo::T_INT,
'article_ids' => ValueInfo::T_STRING,
'assign' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'is_cat' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'cat_id' => ValueInfo::T_INT,
'limit' => ValueInfo::T_INT,
'offset' => ValueInfo::T_INT,
'include_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'skip' => ValueInfo::T_INT,
'filter' => ValueInfo::T_STRING,
'show_excerpt' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'show_content' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'view_mode' => ValueInfo::T_STRING,
'include_attachments' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'since_id' => ValueInfo::T_INT,
'order_by' => ValueInfo::T_STRING,
'sanitize' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'force_update' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'has_sandbox' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'include_header' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'search' => ValueInfo::T_STRING,
'search_mode' => ValueInfo::T_STRING,
'match_on' => ValueInfo::T_STRING,
'mode' => ValueInfo::T_INT,
'field' => ValueInfo::T_INT,
'data' => ValueInfo::T_STRING,
'pref_name' => ValueInfo::T_STRING,
];
// generic error construct
const FATAL_ERR = [
'seq' => null,
'status' => 1,
'content' => ['error' => "NOT_LOGGED_IN"],
];
public function __construct() {
}
public function dispatch(\JKingWeb\Arsse\REST\Request $req): Response {
if ($req->method != "POST") {
// only POST requests are allowed
return new Response(405, self::FATAL_ERR, "application/json", ["Allow: POST"]);
}
if ($req->body) {
// only JSON entities are allowed
if (!preg_match("<^application/json\b|^$>", $req->type)) {
return new Response(415, self::FATAL_ERR, "application/json", ['Accept: application/json']);
}
$data = @json_decode($req->body, true);
if (json_last_error() != \JSON_ERROR_NONE || !is_array($data)) {
// non-JSON input indicates an error
return new Response(400, self::FATAL_ERR);
}
try {
// normalize input
try {
$data['seq'] = isset($data['seq']) ? $data['seq'] : 0;
$data = $this->normalizeInput($data, self::VALID_INPUT, "unix");
7 years ago
} catch (ExceptionType $e) {
throw new Exception("INCORRECT_USAGE");
}
if (strtolower((string) $data['op']) != "login") {
// unless logging in, a session identifier is required
$this->resumeSession((string) $data['sid']);
}
$method = "op".ucfirst($data['op']);
if (!method_exists($this, $method)) {
// because method names are supposed to be case insensitive, we need to try a bit harder to match
$method = strtolower($method);
$map = get_class_methods($this);
$map = array_combine(array_map("strtolower", $map), $map);
if (!array_key_exists($method, $map)) {
// if the method really doesn't exist, throw an exception
throw new Exception("UNKNWON_METHOD", ['method' => $data['op']]);
}
// otherwise retrieve the correct camelCase and continue
$method = $map[$method];
}
return new Response(200, [
'seq' => $data['seq'],
'status' => 0,
'content' => $this->$method($data),
]);
} catch (Exception $e) {
return new Response(200, [
'seq' => $data['seq'],
'status' => 1,
'content' => $e->getData(),
]);
} catch (AbstractException $e) {
return new Response(500);
}
} else {
// absence of a request body indicates an error
return new Response(400, self::FATAL_ERR);
}
}
protected function resumeSession(string $id): bool {
try {
// verify the supplied session is valid
$s = Arsse::$db->sessionResume($id);
} catch (\JKingWeb\Arsse\User\ExceptionSession $e) {
// if not throw an exception
throw new Exception("NOT_LOGGED_IN");
}
// resume the session (currently only the user name)
Arsse::$user->id = $s['user'];
return true;
}
public function opGetApiLevel(array $data): array {
return ['level' => self::LEVEL];
}
public function opGetVersion(array $data): array {
return [
'version' => self::VERSION,
7 years ago
'arsse_version' => Arsse::VERSION,
];
}
public function opLogin(array $data): array {
if (Arsse::$user->auth((string) $data['user'], (string) $data['password'])) {
$id = Arsse::$db->sessionCreate($data['user']);
return [
'session_id' => $id,
'api_level' => self::LEVEL
];
} else {
throw new Exception("LOGIN_ERROR");
}
}
public function opLogout(array $data): array {
Arsse::$db->sessionDestroy(Arsse::$user->id, $data['sid']);
return ['status' => "OK"];
}
public function opIsLoggedIn(array $data): array {
// session validity is already checked by the dispatcher, so we need only return true
return ['status' => true];
}
public function opGetConfig(array $data): array {
return [
'icons_dir' => "feed-icons",
'icons_url' => "feed-icons",
'daemon_is_running' => Service::hasCheckedIn(),
'num_feeds' => Arsse::$db->subscriptionCount(Arsse::$user->id),
];
}
public function opGetUnread(array $data): array {
// simply sum the unread count of each subscription
$out = 0;
foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $sub) {
$out += $sub['unread'];
}
return ['unread' => $out];
}
public function opGetCounters(array $data): array {
$user = Arsse::$user->id;
$starred = Arsse::$db->articleStarred($user);
$fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H")));
$countAll = 0;
$countSubs = 0;
$feeds = [];
$labels = [];
// do a first pass on categories: add the ID to a lookup table and set the unread counter to zero
$categories = Arsse::$db->folderList($user)->getAll();
$catmap = [];
for ($a = 0; $a < sizeof($categories); $a++) {
$catmap[(int) $categories[$a]['id']] = $a;
$categories[$a]['counter'] = 0;
}
// add the "Uncategorized" and "Labels" virtual categories to the list
$catmap[self::CAT_UNCATEGORIZED] = sizeof($categories);
$categories[] = ['id' => self::CAT_UNCATEGORIZED, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Uncategorized"), 'parent' => 0, 'children' => 0, 'counter' => 0];
$catmap[self::CAT_LABELS] = sizeof($categories);
$categories[] = ['id' => self::CAT_LABELS, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Labels"), 'parent' => 0, 'children' => 0, 'counter' => 0];
// prepare data for each subscription; we also add unread counts for their host categories
foreach (Arsse::$db->subscriptionList($user) as $f) {
if ($f['unread']) {
// add the feed to the list of feeds
$feeds[] = ['id' => $f['id'], 'updated' => Date::transform($f['updated'], "iso8601", "sql"),'counter' => $f['unread'], 'has_img' => (int) (strlen((string) $f['favicon']) > 0)];
// add the feed's unread count to the global unread count
$countAll += $f['unread'];
// add the feed's unread count to its category unread count
$categories[$catmap[(int) $f['folder']]]['counter'] += $f['unread'];
}
// increment the global feed count
$countSubs += 1;
}
// prepare data for each non-empty label
foreach (Arsse::$db->labelList($user, false) as $l) {
$unread = $l['articles'] - $l['read'];
$labels[] = ['id' => $this->labelOut($l['id']), 'counter' => $unread, 'auxcounter' => $l['articles']];
$categories[$catmap[self::CAT_LABELS]]['counter'] += $unread;
}
// do a second pass on categories, summing descendant unread counts for ancestors, pruning categories with no unread, and building a final category list
$cats = [];
while ($categories) {
foreach ($categories as $c) {
if ($c['children']) {
// only act on leaf nodes
continue;
}
if ($c['parent']) {
// if the category has a parent, add its counter to the parent's counter, and decrement the parent's child count
$categories[$catmap[$c['parent']]]['counter'] += $c['counter'];
$categories[$catmap[$c['parent']]]['children'] -= 1;
}
if ($c['counter']) {
// if the category's counter is non-zero, add the category to the output list
$cats[] = ['id' => $c['id'], 'kind' => "cat", 'counter' => $c['counter']];
}
// remove the category from the input list
unset($categories[$catmap[$c['id']]]);
}
}
// prepare data for the virtual feeds and other counters
$special = [
['id' => "global-unread", 'counter' => $countAll], //this should not count archived articles, but we do not have an archive
['id' => "subscribed-feeds", 'counter' => $countSubs],
['id' => self::FEED_ARCHIVED, 'counter' => 0, 'auxcounter' => 0], // Archived articles
['id' => self::FEED_STARRED, 'counter' => $starred['unread'], 'auxcounter' => $starred['total']], // Starred articles
['id' => self::FEED_PUBLISHED, 'counter' => 0, 'auxcounter' => 0], // Published articles
['id' => self::FEED_FRESH, 'counter' => $fresh, 'auxcounter' => 0], // Fresh articles
['id' => self::FEED_ALL, 'counter' => $countAll, 'auxcounter' => 0], // All articles
];
return array_merge($special, $labels, $feeds, $cats);
}
public function opGetFeedTree(array $data) : array {
$all = $data['include_empty'] ?? false;
$user = Arsse::$user->id;
$tSpecial = [
'type' => "feed",
'auxcounter' => 0,
'error' => "",
'updated' => "",
];
$out = [];
// get the lists of categories and feeds
$cats = Arsse::$db->folderList($user, null, true)->getAll();
$subs = Arsse::$db->subscriptionList($user)->getAll();
// start with the special feeds
$out[] = [
'name' => Arsse::$lang->msg("API.TTRSS.Category.Special"),
'id' => "CAT:".self::CAT_SPECIAL,
'bare_id' => self::CAT_SPECIAL,
'type' => "category",
'unread' => 0,
'items' => [
array_merge([ // All articles
'name' => Arsse::$lang->msg("API.TTRSS.Feed.All"),
'id' => "FEED:".self::FEED_ALL,
'bare_id' => self::FEED_ALL,
'icon' => "images/folder.png",
'unread' => array_reduce($subs, function($sum, $value) {return $sum + $value['unread'];}, 0), // the sum of all feeds' unread is the total unread
], $tSpecial),
array_merge([ // Fresh articles
'name' => Arsse::$lang->msg("API.TTRSS.Feed.Fresh"),
'id' => "FEED:".self::FEED_FRESH,
'bare_id' => self::FEED_FRESH,
'icon' => "images/fresh.png",
'unread' => Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H"))),
], $tSpecial),
array_merge([ // Starred articles
'name' => Arsse::$lang->msg("API.TTRSS.Feed.Starred"),
'id' => "FEED:".self::FEED_STARRED,
'bare_id' => self::FEED_STARRED,
'icon' => "images/star.png",
'unread' => Arsse::$db->articleStarred($user)['unread'],
], $tSpecial),
array_merge([ // Published articles
'name' => Arsse::$lang->msg("API.TTRSS.Feed.Published"),
'id' => "FEED:".self::FEED_PUBLISHED,
'bare_id' => self::FEED_PUBLISHED,
'icon' => "images/feed.png",
'unread' => 0, // TODO: unread count should be populated if the Published feed is ever implemented
], $tSpecial),
array_merge([ // Archived articles
'name' => Arsse::$lang->msg("API.TTRSS.Feed.Archived"),
'id' => "FEED:".self::FEED_ARCHIVED,
'bare_id' => self::FEED_ARCHIVED,
'icon' => "images/archive.png",
'unread' => 0, // Article archiving is not exposed by the API, so this is always zero
], $tSpecial),
array_merge([ // Recently read
'name' => Arsse::$lang->msg("API.TTRSS.Feed.Read"),
'id' => "FEED:".self::FEED_READ,
'bare_id' => self::FEED_READ,
'icon' => "images/time.png",
'unread' => 0, // this is by definition zero; unread articles do not appear in this feed
], $tSpecial),
],
];
// next prepare labels
$items = [];
$unread = 0;
// 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)
foreach (Arsse::$db->labelList($user, true) as $l) {
$items[] = [
'name' => $l['name'],
'id' => "FEED:".$this->labelOut($l['id']),
'bare_id' => $this->labelOut($l['id']),
'unread' => 0,
'icon' => "images/label.png",
'type' => "feed",
'auxcounter' => 0,
'error' => "",
'updated' => "",
'fg_color' => "",
'bg_color' => "",
];
$unread += ($l['articles'] - $l['read']);
}
// if there are labels, all the label category,
if ($items) {
$out[] = [
'name' => Arsse::$lang->msg("API.TTRSS.Category.Labels"),
'id' => "CAT:".self::CAT_LABELS,
'bare_id' => self::CAT_LABELS,
'type' => "category",
'unread' => $unread,
'items' => $items,
];
}
// get the lists of categories and feeds
$cats = Arsse::$db->folderList($user, null, true)->getAll();
$subs = Arsse::$db->subscriptionList($user)->getAll();
// process all the top-level categories; their contents are gathered recursively in another function
$items = $this->enumerateCategories($cats, $subs, null, $all);
$out = array_merge($out, $items['list']);
// process uncategorized feeds; exclude the "Uncategorized" category if there are no orphan feeds and we're not displaying empties
$items = $this->enumerateFeeds($subs, null);
if ($items || !$all) {
$out[] = [
'name' => Arsse::$lang->msg("API.TTRSS.Category.Uncategorized"),
'id' => "CAT:".self::CAT_UNCATEGORIZED,
'bare_id' => self::CAT_UNCATEGORIZED,
'type' => "category",
'auxcounter' => 0,
'unread' => 0,
'child_unread' => 0,
'checkbox' => false,
'parent_id' => null,
'param' => Arsse::$lang->msg("API.TTRSS.FeedCount", sizeof($items)),
'items' => $items,
];
}
// return the result wrapped in some boilerplate
return ['categories' => ['identifier' => "id", 'label' => "name", 'items' => $out]];
}
protected function enumerateFeeds(array $subs, int $parent = null): array {
$out = [];
foreach ($subs as $s) {
if ($s['folder'] != $parent) {
continue;
}
$out[] = [
'name' => $s['title'],
'id' => "FEED:".$s['id'],
'bare_id' => $s['id'],
'icon' => $s['favicon'] ? "feed-icons/".$s['id'].".ico" : false,
'error' => (string) $s['err_msg'],
'param' => Date::transform($s['updated'], "iso8601", "sql"),
'unread' => 0,
'auxcounter' => 0,
'checkbox' => false,
// NOTE: feeds don't have a type property (even though both labels and special feeds do); don't ask me why
];
}
return $out;
}
protected function enumerateCategories(array $cats, array $subs, int $parent = null, bool $all = false): array {
$out = [];
$feedTotal = 0;
foreach ($cats as $c) {
if ($c['parent'] != $parent || (!$all && !($c['children'] + $c['feeds']))) {
// if the category is the wrong level, or if it's empty and we're not including empties, skip it
continue;
}
$children = $c['children'] ? $this->enumerateCategories($cats, $subs, $c['id'], $all) : ['list' => [], 'feeds' => 0];
$feeds = $c['feeds'] ? $this->enumerateFeeds($subs, $c['id']) : [];
$count = sizeof($feeds) + $children['feeds'];
$out[] = [
'name' => $c['name'],
'id' => "CAT:".$c['id'],
'bare_id' => $c['id'],
'parent_id' => $c['parent'], // 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
'type' => "category",
'auxcounter' => 0,
'unread' => 0,
'child_unread' => 0,
'checkbox' => false,
'param' => Arsse::$lang->msg("API.TTRSS.FeedCount", $count),
'items' => array_merge($children['list'], $feeds),
];
$feedTotal += $count;
}
return ['list' => $out, 'feeds' => $feedTotal];
}
public function opGetCategories(array $data): array {
// normalize input
$all = $data['include_empty'] ?? false;
$read = !($data['unread_only'] ?? false);
$deep = !($data['enable_nested'] ?? false);
$user = Arsse::$user->id;
// for each category, add the ID to a lookup table, set the number of unread to zero, and assign an increasing order index
$cats = Arsse::$db->folderList($user, null, $deep)->getAll();
$map = [];
for ($a = 0; $a < sizeof($cats); $a++) {
$map[$cats[$a]['id']] = $a;
$cats[$a]['unread'] = 0;
$cats[$a]['order'] = $a + 1;
}
// add the "Uncategorized", "Special", and "Labels" virtual categories to the list
$map[self::CAT_UNCATEGORIZED] = sizeof($cats);
$cats[] = ['id' => self::CAT_UNCATEGORIZED, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Uncategorized"), 'children' => 0, 'unread' => 0, 'feeds' => 0];
$map[self::CAT_SPECIAL] = sizeof($cats);
$cats[] = ['id' => self::CAT_SPECIAL, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Special"), 'children' => 0, 'unread' => 0, 'feeds' => 6];
$map[self::CAT_LABELS] = sizeof($cats);
$cats[] = ['id' => self::CAT_LABELS, 'name' => Arsse::$lang->msg("API.TTRSS.Category.Labels"), 'children' => 0, 'unread' => 0, 'feeds' => 0];
// for each subscription, add the unread count to its category, and increment the category's feed count
$subs = Arsse::$db->subscriptionList($user);
foreach ($subs as $sub) {
// note we use top_folder if we're in "nested" mode
$f = $map[(int) ($deep ? $sub['folder'] : $sub['top_folder'])];
$cats[$f]['unread'] += $sub['unread'];
if (!$cats[$f]['id']) {
$cats[$f]['feeds'] += 1;
}
}
// for each label, add the unread count to the labels category, and increment the labels category's feed count
$labels = Arsse::$db->labelList($user);
$f = $map[self::CAT_LABELS];
foreach ($labels as $label) {
$cats[$f]['unread'] += $label['articles'] - $label['read'];
$cats[$f]['feeds'] += 1;
}
// get the unread counts for the special feeds
// FIXME: this is pretty inefficient
$f = $map[self::CAT_SPECIAL];
$cats[$f]['unread'] += Arsse::$db->articleStarred($user)['unread']; // starred
$cats[$f]['unread'] += Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H"))); // fresh
if (!$read) {
// if we're only including unread entries, remove any categories with zero unread items (this will by definition also exclude empties)
$count = sizeof($cats);
for ($a = 0; $a < $count; $a++) {
if (!$cats[$a]['unread']) {
unset($cats[$a]);
}
}
$cats = array_values($cats);
} elseif (!$all) {
// otherwise if we're not including empty entries, remove categories with no children and no feeds
$count = sizeof($cats);
for ($a = 0; $a < $count; $a++) {
if (($cats[$a]['children'] + $cats[$a]['feeds']) < 1) {
unset($cats[$a]);
}
}
$cats = array_values($cats);
}
// transform the result and return
$out = [];
for ($a = 0; $a < sizeof($cats); $a++) {
$out[] = $this->fieldMapNames($cats[$a], [
'id' => "id",
'title' => "name",
'unread' => "unread",
'order_id' => "order",
]);
}
return $out;
}
public function opAddCategory(array $data) {
$in = [
'name' => $data['caption'],
'parent' => $data['parent_id'],
];
try {
return Arsse::$db->folderAdd(Arsse::$user->id, $in);
} catch (ExceptionInput $e) {
switch ($e->getCode()) {
case 10236: // folder already exists
// retrieve the ID of the existing folder; duplicating a folder silently returns the existing one
$folders = Arsse::$db->folderList(Arsse::$user->id, $in['parent'], false);
foreach ($folders as $folder) {
if ($folder['name']==$in['name']) {
return (int) $folder['id'];
}
}
return false; // @codeCoverageIgnore
case 10235: // parent folder does not exist; this returns false as an ID
return false;
default: // other errors related to input
throw new Exception("INCORRECT_USAGE");
}
}
}
public function opRemoveCategory(array $data) {
if (!ValueInfo::id($data['category_id'])) {
// if the folder is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
try {
// attempt to remove the folder
Arsse::$db->folderRemove(Arsse::$user->id, (int) $data['category_id']);
7 years ago
} catch (ExceptionInput $e) {
// ignore all errors
}
return null;
}
public function opMoveCategory(array $data) {
if (!ValueInfo::id($data['category_id']) || !ValueInfo::id($data['parent_id'], true)) {
// if the folder or parent is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
$in = [
'parent' => (int) $data['parent_id'],
];
try {
// try to move the folder
Arsse::$db->folderPropertiesSet(Arsse::$user->id, (int) $data['category_id'], $in);
7 years ago
} catch (ExceptionInput $e) {
// ignore all errors
}
return null;
}
public function opRenameCategory(array $data) {
$info = ValueInfo::str($data['caption']);
if (!ValueInfo::id($data['category_id']) || !($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) {
// if the folder or its new name are invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
$in = [
'name' => $data['caption'],
];
try {
// try to rename the folder
Arsse::$db->folderPropertiesSet(Arsse::$user->id, $data['category_id'], $in);
7 years ago
} catch (ExceptionInput $e) {
// ignore all errors
}
return null;
}
protected function feedError(FeedException $e): array {
// N.B.: we don't return code 4 (multiple feeds discovered); we simply pick the first feed discovered
switch ($e->getCode()) {
7 years ago
case 10502: // invalid URL
return ['code' => 2, 'message' => $e->getMessage()];
case 10521: // no feeds discovered
return ['code' => 3, 'message' => $e->getMessage()];
case 10511:
case 10512:
case 10522: // malformed data
return ['code' => 6, 'message' => $e->getMessage()];
default: // unable to download
return ['code' => 5, 'message' => $e->getMessage()];
}
}
public function opSubscribeToFeed(array $data): array {
if (!$data['feed_url'] || !ValueInfo::id($data['category_id'], true)) {
// if the feed URL or the category ID is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
$url = (string) $data['feed_url'];
$folder = (int) $data['category_id'];
$fetchUser = (string) $data['login'];
$fetchPassword = (string) $data['password'];
// check to make sure the requested folder exists before doing anything else, if one is specified
if ($folder) {
try {
Arsse::$db->folderPropertiesGet(Arsse::$user->id, $folder);
} catch (ExceptionInput $e) {
// 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
return ['code' => 1, 'feed_id' => 0];
}
}
try {
$id = Arsse::$db->subscriptionAdd(Arsse::$user->id, $url, $fetchUser, $fetchPassword);
} catch (ExceptionInput $e) {
// subscription already exists; retrieve the existing ID and return that with the correct code
for ($triedDiscovery = 0; $triedDiscovery <= 1; $triedDiscovery++) {
$subs = Arsse::$db->subscriptionList(Arsse::$user->id);
$id = false;
foreach ($subs as $sub) {
if ($sub['url']===$url) {
$id = (int) $sub['id'];
break;
}
}
if ($id) {
break;
} elseif (!$triedDiscovery) {
// 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()
try {
$url = Feed::discover($url, $fetchUser, $fetchPassword);
7 years ago
} catch (FeedException $e) {
// feed errors (handled above)
return $this->feedError($e);
}
}
}
return ['code' => 0, 'feed_id' => $id];
} catch (FeedException $e) {
// feed errors (handled above)
return $this->feedError($e);
}
// if all went well, move the new subscription to the requested folder (if one was requested)
try {
Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, ['folder' => $folder]);
} catch (ExceptionInput $e) {
// ignore errors
}
return ['code' => 1, 'feed_id' => $id];
}
public function opUnsubscribeFeed(array $data): array {
try {
// attempt to remove the feed
Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $data['feed_id']);
7 years ago
} catch (ExceptionInput $e) {
throw new Exception("FEED_NOT_FOUND");
}
return ['status' => "OK"];
}
public function opMoveFeed(array $data) {
if (!ValueInfo::id($data['feed_id']) || !isset($data['category_id']) || !ValueInfo::id($data['category_id'], true)) {
// if the feed or folder is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
$in = [
'folder' => $data['category_id'],
];
try {
// try to move the feed
Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $data['feed_id'], $in);
7 years ago
} catch (ExceptionInput $e) {
// ignore all errors
}
return null;
}
public function opRenameFeed(array $data) {
$info = ValueInfo::str($data['caption']);
if (!ValueInfo::id($data['feed_id']) || !($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) {
// if the feed ID or name is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
$in = [
'name' => $data['caption'],
];
try {
// try to rename the feed
Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $data['feed_id'], $in);
7 years ago
} catch (ExceptionInput $e) {
// ignore all errors
}
return null;
}
public function opUpdateFeed(array $data): array {
if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id'])) {
// if the feed is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
try {
Arsse::$db->feedUpdate(Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, $data['feed_id'])['feed']);
7 years ago
} catch (ExceptionInput $e) {
throw new Exception("FEED_NOT_FOUND");
}
return ['status' => "OK"];
}
protected function labelIn($id, bool $throw = true): int {
if (!(ValueInfo::int($id) & ValueInfo::NEG) || $id > (-1 - self::LABEL_OFFSET)) {
if ($throw) {
throw new Exception("INCORRECT_USAGE");
} else {
return 0;
}
}
return (abs($id) - self::LABEL_OFFSET);
}
protected function labelOut(int $id): int {
return ($id * -1 - self::LABEL_OFFSET);
}
public function opGetLabels(array $data): array {
// this function doesn't complain about invalid article IDs
$article = ValueInfo::id($data['article_id']) ? $data['article_id'] : 0;
try {
$list = $article ? Arsse::$db->articleLabelsGet(Arsse::$user->id, $article) : [];
} catch (ExceptionInput $e) {
$list = [];
}
$out = [];
foreach (Arsse::$db->labelList(Arsse::$user->id) as $l) {
$out[] = [
'id' => $this->labelOut($l['id']),
'caption' => $l['name'],
'fg_color' => "",
'bg_color' => "",
'checked' => in_array($l['id'], $list),
];
7 years ago
}
return $out;
}
public function opAddLabel(array $data) {
$in = [
'name' => (string) $data['caption'],
];
try {
return $this->labelOut(Arsse::$db->labelAdd(Arsse::$user->id, $in));
} catch (ExceptionInput $e) {
switch ($e->getCode()) {
case 10236: // label already exists
// retrieve the ID of the existing label; duplicating a label silently returns the existing one
return $this->labelOut(Arsse::$db->labelPropertiesGet(Arsse::$user->id, $in['name'], true)['id']);
default: // other errors related to input
throw new Exception("INCORRECT_USAGE");
}
}
}
public function opRemoveLabel(array $data) {
// normalize the label ID; missing or invalid IDs are rejected
$id = $this->labelIn($data['label_id']);
try {
// attempt to remove the label
Arsse::$db->labelRemove(Arsse::$user->id, $id);
7 years ago
} catch (ExceptionInput $e) {
// ignore all errors
}
return null;
}
public function opRenameLabel(array $data) {
// normalize input; missing or invalid IDs are rejected
$id = $this->labelIn($data['label_id']);
$name = (string) $data['caption'];
try {
// try to rename the folder
Arsse::$db->labelPropertiesSet(Arsse::$user->id, $id, ['name' => $name]);
7 years ago
} catch (ExceptionInput $e) {
if ($e->getCode()==10237) {
// if the supplied ID was invalid, report an error; other errors are to be ignored
throw new Exception("INCORRECT_USAGE");
}
}
return null;
}
public function opSetArticleLabel(array $data): array {
$label = $this->labelIn($data['label_id']);
$articles = explode(",", (string) $data['article_ids']);
$assign = $data['assign'] ?? false;
$out = 0;
$in = array_chunk($articles, 50);
for ($a = 0; $a < sizeof($in); $a++) {
// initialize the matching context
$c = new Context;
$c->articles($in[$a]);
try {
$out += Arsse::$db->labelArticlesSet(Arsse::$user->id, $label, $c, !$assign);
} catch (ExceptionInput $e) {
}
}
return ['status' => "OK", 'updated' => $out];
}
public function opCatchUpFeed(array $data): array {
$id = $data['feed_id'] ?? self::FEED_ARCHIVED;
$cat = $data['is_cat'] ?? false;
$out = ['status' => "OK"];
// first prepare the context; unsupported contexts simply return early, whereas some valid contexts are special cases
$c = new Context;
if ($cat) { // categories
switch ($id) {
case self::CAT_SPECIAL:
case self::CAT_NOT_SPECIAL:
case self::CAT_ALL:
// not valid
return $out;
case self::CAT_UNCATEGORIZED:
// this is a special case
try {
$tr = Arsse::$db->begin();
// filter the subscription list to return only uncategorized, and get their IDs
$list = array_column(Arsse::$db->subscriptionList(Arsse::$user->id, null, false)->getAll(), "id");
// perform marking for each applicable subscription
foreach ($list as $id) {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], (new Context)->subscription($id));
}
$tr->commit();
} catch (ExceptionInput $e) { // @codeCoverageIgnore
// ignore errors; none should occur
}
return $out;
case self::CAT_LABELS:
// this is also a special case
try {
$tr = Arsse::$db->begin();
// list all non-empty labels
$list = array_column(Arsse::$db->labelList(Arsse::$user->id, false)->getAll(), "id");
// perform marking for each label
foreach ($list as $id) {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], (new Context)->label($id));
}
$tr->commit();
} catch (ExceptionInput $e) { // @codeCoverageIgnore
// ignore errors; none should occur
}
return $out;
default:
// any actual category
$c->folder($id);
break;
}
} else { // feeds
if ($this->labelIn($id, false)) { // labels
$c->label($this->labelIn($id));
} else {
switch ($id) {
case self::FEED_ARCHIVED:
// not implemented (also, evidently, not implemented in TTRSS)
return $out;
case self::FEED_STARRED:
$c->starred(true);
break;
case self::FEED_PUBLISHED:
// not implemented
// TODO: if the Published feed is implemented, the catchup function needs to be modified accordingly
return $out;
case self::FEED_FRESH:
$c->modifiedSince(Date::sub("PT24H"));
break;
case self::FEED_ALL:
// no context needed here
break;
case self::FEED_READ:
// everything in the Recently read feed is, by definition, already read
return $out;
default:
// any actual feed
$c->subscription($id);
}
}
}
// perform the marking
try {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
} catch (ExceptionInput $e) {
// ignore all errors
}
// return boilerplate output
return $out;
}
}