2017-09-25 03:32:21 +00:00
< ? php
declare ( strict_types = 1 );
namespace JKingWeb\Arsse\REST\TinyTinyRSS ;
2017-10-03 14:43:09 +00:00
use JKingWeb\Arsse\Feed ;
2017-09-25 03:32:21 +00:00
use JKingWeb\Arsse\Arsse ;
use JKingWeb\Arsse\User ;
use JKingWeb\Arsse\Service ;
2017-10-07 16:46:05 +00:00
use JKingWeb\Arsse\Misc\Date ;
2017-09-25 03:32:21 +00:00
use JKingWeb\Arsse\Misc\Context ;
2017-10-01 02:15:55 +00:00
use JKingWeb\Arsse\Misc\ValueInfo ;
2017-09-25 03:32:21 +00:00
use JKingWeb\Arsse\AbstractException ;
2017-10-20 13:54:08 +00:00
use JKingWeb\Arsse\ExceptionType ;
2017-09-25 03:32:21 +00:00
use JKingWeb\Arsse\Db\ExceptionInput ;
use JKingWeb\Arsse\Feed\Exception as FeedException ;
use JKingWeb\Arsse\REST\Response ;
2017-10-01 02:15:55 +00:00
/*
Protocol difference so far :
2017-10-11 16:55:50 +00:00
- Handling of incorrect Content - Type and / or HTTP method is different
2017-10-31 22:12:50 +00:00
- TT - RSS accepts whitespace - only names for categories , labels , and feeds ; we do not
2017-10-01 02:15:55 +00:00
- TT - RSS allows two folders to share the same name under the same parent ; we do not
2017-10-31 22:12:50 +00:00
- TT - RSS requires the user to choose in the face of multiple found feeds during discovery ; we use the first one ( picoFeed limitation )
2017-10-30 17:11:27 +00:00
- Session lifetime is much shorter by default
2017-10-05 21:42:12 +00:00
- Categories and feeds will always be sorted alphabetically ( the protocol does not allow for clients to re - order )
2017-10-11 16:55:50 +00:00
- 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 )
2017-10-30 17:11:27 +00:00
- 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
2017-10-31 03:18:43 +00:00
- Top - level categories in getFeedTree have a 'parent_id' property ( set to null ); in TT - RSS the property is absent
2017-10-01 02:15:55 +00:00
*/
2017-09-25 03:32:21 +00:00
class API extends \JKingWeb\Arsse\REST\AbstractHandler {
const LEVEL = 14 ;
const VERSION = " 17.4 " ;
2017-10-11 16:55:50 +00:00
const LABEL_OFFSET = 1024 ;
2017-10-30 20:18:09 +00:00
// 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
2017-10-15 16:47:07 +00:00
const VALID_INPUT = [
2017-10-20 13:54:08 +00:00
'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 ,
2017-10-31 13:39:06 +00:00
'article_id' => ValueInfo :: T_MIXED , // single integer or comma-separated list in getArticle
2017-10-20 13:54:08 +00:00
'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 ,
2017-10-20 22:17:47 +00:00
'pref_name' => ValueInfo :: T_STRING ,
2017-10-15 16:47:07 +00:00
];
2017-10-30 20:18:09 +00:00
// generic error construct
2017-09-25 12:15:39 +00:00
const FATAL_ERR = [
2017-10-15 16:47:07 +00:00
'seq' => null ,
'status' => 1 ,
2017-09-25 12:15:39 +00:00
'content' => [ 'error' => " NOT_LOGGED_IN " ],
];
2017-09-25 03:32:21 +00:00
public function __construct () {
}
public function dispatch ( \JKingWeb\Arsse\REST\Request $req ) : Response {
if ( $req -> method != " POST " ) {
// only POST requests are allowed
2017-09-25 12:15:39 +00:00
return new Response ( 405 , self :: FATAL_ERR , " application/json " , [ " Allow: POST " ]);
2017-09-25 03:32:21 +00:00
}
if ( $req -> body ) {
// only JSON entities are allowed
if ( ! preg_match ( " <^application/json \ b|^ $ > " , $req -> type )) {
2017-09-25 12:15:39 +00:00
return new Response ( 415 , self :: FATAL_ERR , " application/json " , [ 'Accept: application/json' ]);
2017-09-25 03:32:21 +00:00
}
$data = @ json_decode ( $req -> body , true );
if ( json_last_error () != \JSON_ERROR_NONE || ! is_array ( $data )) {
// non-JSON input indicates an error
2017-09-25 12:15:39 +00:00
return new Response ( 400 , self :: FATAL_ERR );
2017-09-25 03:32:21 +00:00
}
try {
2017-10-20 13:54:08 +00:00
// normalize input
try {
$data [ 'seq' ] = isset ( $data [ 'seq' ]) ? $data [ 'seq' ] : 0 ;
$data = $this -> normalizeInput ( $data , self :: VALID_INPUT , " unix " );
2017-10-20 23:02:42 +00:00
} catch ( ExceptionType $e ) {
2017-10-20 13:54:08 +00:00
throw new Exception ( " INCORRECT_USAGE " );
}
2017-10-11 16:55:50 +00:00
if ( strtolower (( string ) $data [ 'op' ]) != " login " ) {
// unless logging in, a session identifier is required
2017-10-20 13:54:08 +00:00
$this -> resumeSession (( string ) $data [ 'sid' ]);
2017-09-25 03:32:21 +00:00
}
$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 );
2017-09-28 14:16:24 +00:00
if ( ! array_key_exists ( $method , $map )) {
2017-09-25 03:32:21 +00:00
// 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
2017-09-25 12:15:39 +00:00
return new Response ( 400 , self :: FATAL_ERR );
2017-09-25 03:32:21 +00:00
}
}
2017-10-20 13:54:08 +00:00
protected function resumeSession ( string $id ) : bool {
2017-09-25 03:32:21 +00:00
try {
// verify the supplied session is valid
2017-10-20 13:54:08 +00:00
$s = Arsse :: $db -> sessionResume ( $id );
2017-09-25 03:32:21 +00:00
} 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 ;
}
2017-09-25 14:08:37 +00:00
public function opGetApiLevel ( array $data ) : array {
2017-09-25 03:32:21 +00:00
return [ 'level' => self :: LEVEL ];
}
public function opGetVersion ( array $data ) : array {
return [
'version' => self :: VERSION ,
2017-10-02 19:42:15 +00:00
'arsse_version' => Arsse :: VERSION ,
2017-09-25 03:32:21 +00:00
];
}
2017-09-25 14:08:37 +00:00
public function opLogin ( array $data ) : array {
2017-10-20 13:54:08 +00:00
if ( Arsse :: $user -> auth (( string ) $data [ 'user' ], ( string ) $data [ 'password' ])) {
2017-09-25 03:32:21 +00:00
$id = Arsse :: $db -> sessionCreate ( $data [ 'user' ]);
return [
2017-09-28 14:16:24 +00:00
'session_id' => $id ,
2017-09-25 03:32:21 +00:00
'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 ];
}
2017-09-27 02:45:54 +00:00
2017-10-11 16:55:50 +00:00
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
2017-10-30 20:18:09 +00:00
$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 ];
2017-10-11 16:55:50 +00:00
// 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' ]];
2017-10-30 20:18:09 +00:00
$categories [ $catmap [ self :: CAT_LABELS ]][ 'counter' ] += $unread ;
2017-10-11 16:55:50 +00:00
}
// 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 = [
2017-10-30 20:18:09 +00:00
[ '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
2017-10-11 16:55:50 +00:00
];
return array_merge ( $special , $labels , $feeds , $cats );
}
2017-10-30 17:11:27 +00:00
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 " ),
2017-10-30 20:18:09 +00:00
'id' => " CAT: " . self :: CAT_SPECIAL ,
'bare_id' => self :: CAT_SPECIAL ,
2017-10-30 17:11:27 +00:00
'type' => " category " ,
'unread' => 0 ,
'items' => [
array_merge ([ // All articles
'name' => Arsse :: $lang -> msg ( " API.TTRSS.Feed.All " ),
2017-10-30 20:18:09 +00:00
'id' => " FEED: " . self :: FEED_ALL ,
'bare_id' => self :: FEED_ALL ,
2017-10-30 17:11:27 +00:00
'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 " ),
2017-10-30 20:18:09 +00:00
'id' => " FEED: " . self :: FEED_FRESH ,
'bare_id' => self :: FEED_FRESH ,
2017-10-30 17:11:27 +00:00
'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 " ),
2017-10-30 20:18:09 +00:00
'id' => " FEED: " . self :: FEED_STARRED ,
'bare_id' => self :: FEED_STARRED ,
2017-10-30 17:11:27 +00:00
'icon' => " images/star.png " ,
'unread' => Arsse :: $db -> articleStarred ( $user )[ 'unread' ],
], $tSpecial ),
array_merge ([ // Published articles
'name' => Arsse :: $lang -> msg ( " API.TTRSS.Feed.Published " ),
2017-10-30 20:18:09 +00:00
'id' => " FEED: " . self :: FEED_PUBLISHED ,
'bare_id' => self :: FEED_PUBLISHED ,
2017-10-30 17:11:27 +00:00
'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 " ),
2017-10-30 20:18:09 +00:00
'id' => " FEED: " . self :: FEED_ARCHIVED ,
'bare_id' => self :: FEED_ARCHIVED ,
2017-10-30 17:11:27 +00:00
'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 " ),
2017-10-30 20:18:09 +00:00
'id' => " FEED: " . self :: FEED_READ ,
'bare_id' => self :: FEED_READ ,
2017-10-30 17:11:27 +00:00
'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 " ),
2017-10-30 20:18:09 +00:00
'id' => " CAT: " . self :: CAT_LABELS ,
'bare_id' => self :: CAT_LABELS ,
2017-10-30 17:11:27 +00:00
'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 " ),
2017-10-30 20:18:09 +00:00
'id' => " CAT: " . self :: CAT_UNCATEGORIZED ,
'bare_id' => self :: CAT_UNCATEGORIZED ,
2017-10-30 17:11:27 +00:00
'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 ,
2017-10-31 03:18:43 +00:00
// NOTE: feeds don't have a type property (even though both labels and special feeds do); don't ask me why
2017-10-30 17:11:27 +00:00
];
}
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' ],
2017-10-31 03:18:43 +00:00
'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
2017-10-30 17:11:27 +00:00
'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 ];
}
2017-10-07 16:46:05 +00:00
public function opGetCategories ( array $data ) : array {
// normalize input
2017-10-20 13:54:08 +00:00
$all = $data [ 'include_empty' ] ? ? false ;
$read = ! ( $data [ 'unread_only' ] ? ? false );
$deep = ! ( $data [ 'enable_nested' ] ? ? false );
2017-10-07 16:46:05 +00:00
$user = Arsse :: $user -> id ;
2017-10-11 16:55:50 +00:00
// for each category, add the ID to a lookup table, set the number of unread to zero, and assign an increasing order index
2017-10-07 16:46:05 +00:00
$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
2017-10-30 20:18:09 +00:00
$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 ];
2017-10-07 16:46:05 +00:00
// 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' ];
2017-10-11 16:55:50 +00:00
if ( ! $cats [ $f ][ 'id' ]) {
$cats [ $f ][ 'feeds' ] += 1 ;
}
2017-10-07 16:46:05 +00:00
}
// for each label, add the unread count to the labels category, and increment the labels category's feed count
$labels = Arsse :: $db -> labelList ( $user );
2017-10-30 20:18:09 +00:00
$f = $map [ self :: CAT_LABELS ];
2017-10-07 16:46:05 +00:00
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
2017-10-30 20:18:09 +00:00
$f = $map [ self :: CAT_SPECIAL ];
2017-10-11 16:55:50 +00:00
$cats [ $f ][ 'unread' ] += Arsse :: $db -> articleStarred ( $user )[ 'unread' ]; // starred
2017-10-07 16:46:05 +00:00
$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 ;
}
2017-09-27 02:45:54 +00:00
public function opAddCategory ( array $data ) {
$in = [
2017-10-20 13:54:08 +00:00
'name' => $data [ 'caption' ],
'parent' => $data [ 'parent_id' ],
2017-09-27 02:45:54 +00:00
];
try {
return Arsse :: $db -> folderAdd ( Arsse :: $user -> id , $in );
} catch ( ExceptionInput $e ) {
switch ( $e -> getCode ()) {
2017-10-01 02:15:55 +00:00
case 10236 : // folder already exists
// retrieve the ID of the existing folder; duplicating a folder silently returns the existing one
2017-09-27 02:45:54 +00:00
$folders = Arsse :: $db -> folderList ( Arsse :: $user -> id , $in [ 'parent' ], false );
foreach ( $folders as $folder ) {
if ( $folder [ 'name' ] == $in [ 'name' ]) {
return ( int ) $folder [ 'id' ];
}
}
2017-10-03 14:43:09 +00:00
return false ; // @codeCoverageIgnore
2017-10-01 02:15:55 +00:00
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 " );
2017-09-27 02:45:54 +00:00
}
}
}
2017-10-01 02:15:55 +00:00
public function opRemoveCategory ( array $data ) {
2017-10-20 13:54:08 +00:00
if ( ! ValueInfo :: id ( $data [ 'category_id' ])) {
2017-10-01 02:15:55 +00:00
// 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' ]);
2017-10-20 23:02:42 +00:00
} catch ( ExceptionInput $e ) {
2017-10-01 02:15:55 +00:00
// ignore all errors
}
return null ;
}
public function opMoveCategory ( array $data ) {
2017-10-20 13:54:08 +00:00
if ( ! ValueInfo :: id ( $data [ 'category_id' ]) || ! ValueInfo :: id ( $data [ 'parent_id' ], true )) {
2017-10-01 02:15:55 +00:00
// 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 );
2017-10-20 23:02:42 +00:00
} catch ( ExceptionInput $e ) {
2017-10-01 02:15:55 +00:00
// ignore all errors
}
return null ;
}
public function opRenameCategory ( array $data ) {
$info = ValueInfo :: str ( $data [ 'caption' ]);
2017-10-20 13:54:08 +00:00
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
2017-10-01 02:15:55 +00:00
throw new Exception ( " INCORRECT_USAGE " );
}
$in = [
2017-10-20 13:54:08 +00:00
'name' => $data [ 'caption' ],
2017-10-01 02:15:55 +00:00
];
try {
// try to rename the folder
2017-10-20 13:54:08 +00:00
Arsse :: $db -> folderPropertiesSet ( Arsse :: $user -> id , $data [ 'category_id' ], $in );
2017-10-20 23:02:42 +00:00
} catch ( ExceptionInput $e ) {
2017-10-01 02:15:55 +00:00
// ignore all errors
}
return null ;
}
2017-11-02 21:17:46 +00:00
public function opGetFeeds ( array $data ) : array {
$user = Arsse :: $user -> id ;
// normalize input
$cat = $data [ 'cat_id' ] ? ? 0 ;
$unread = $data [ 'unread_only' ] ? ? false ;
$limit = $data [ 'limit' ] ? ? 0 ;
$offset = $data [ 'offset' ] ? ? 0 ;
$nested = $data [ 'include_nested' ] ? ? false ;
// if a special category was selected, nesting does not apply
if ( ! ValueInfo :: id ( $cat )) {
$nested = false ;
// if the All, Special, or Labels category was selected, pagination also does not apply
if ( in_array ( $cat , [ self :: CAT_ALL , self :: CAT_SPECIAL , self :: CAT_LABELS ])) {
$limit = 0 ;
$offset = 0 ;
}
}
// retrieve or build the list of relevant feeds
$out = [];
$subs = [];
$count = 0 ;
// if the category is the special Labels category or the special All category (which includes labels), add labels to the list
if ( $cat == self :: CAT_ALL || $cat == self :: CAT_LABELS ) {
// NOTE: unused labels are not included
foreach ( Arsse :: $db -> labelList ( $user , false ) as $l ) {
if ( $unread && ! $l [ 'unread' ]) {
continue ;
}
$out [] = [
'id' => $this -> labelOut ( $l [ 'id' ]),
'title' => $l [ 'name' ],
'unread' => $l [ 'unread' ],
'cat_id' => self :: CAT_LABELS ,
];
}
}
// if the category is the special Special (!) category or the special All category (which includes "special" feeds), add those feeds to the list
if ( $cat == self :: CAT_ALL || $cat == self :: CAT_SPECIAL ) {
// gather some statistics
$starred = Arsse :: $db -> articleStarred ( $user )[ 'unread' ];
$fresh = Arsse :: $db -> articleCount ( $user , ( new Context ) -> unread ( true ) -> modifiedSince ( Date :: sub ( " PT24H " )));
$global = Arsse :: $db -> articleCount ( $user , ( new Context ) -> unread ( true ));
$published = 0 ; // TODO: if the Published feed is implemented, the getFeeds method needs to be adjusted accordingly
$archived = 0 ; // the archived feed is non-functional in the TT-RSS protocol itself
// build the list; exclude anything with zero unread if requested
if ( ! $unread || $starred ) {
$out [] = [
'id' => self :: FEED_STARRED ,
'title' => Arsse :: $lang -> msg ( " API.TTRSS.Feed.Starred " ),
'unread' => $starred ,
'cat_id' => self :: CAT_SPECIAL ,
];
}
if ( ! $unread || $published ) {
$out [] = [
'id' => self :: FEED_PUBLISHED ,
'title' => Arsse :: $lang -> msg ( " API.TTRSS.Feed.Published " ),
'unread' => $published ,
'cat_id' => self :: CAT_SPECIAL ,
];
}
if ( ! $unread || $fresh ) {
$out [] = [
'id' => self :: FEED_FRESH ,
'title' => Arsse :: $lang -> msg ( " API.TTRSS.Feed.Fresh " ),
'unread' => $fresh ,
'cat_id' => self :: CAT_SPECIAL ,
];
}
if ( ! $unread || $global ) {
$out [] = [
'id' => self :: FEED_ALL ,
'title' => Arsse :: $lang -> msg ( " API.TTRSS.Feed.All " ),
'unread' => $global ,
'cat_id' => self :: CAT_SPECIAL ,
];
}
if ( ! $unread ) {
$out [] = [
'id' => self :: FEED_READ ,
'title' => Arsse :: $lang -> msg ( " API.TTRSS.Feed.Read " ),
'unread' => 0 , // zero by definition
'cat_id' => self :: CAT_SPECIAL ,
];
}
if ( ! $unread || $archived ) {
$out [] = [
'id' => self :: FEED_ARCHIVED ,
'title' => Arsse :: $lang -> msg ( " API.TTRSS.Feed.Archived " ),
'unread' => $archived ,
'cat_id' => self :: CAT_SPECIAL ,
];
}
}
// categories and real feeds have a sequential order index; we don't store this, so we just increment with each entry from here
$order = 0 ;
// if a "nested" list was requested, append the category's child categories to the putput
if ( $nested ) {
try {
// NOTE: the list is a flat one: it includes children, but not other descendents
foreach ( Arsse :: $db -> folderList ( $user , $cat , false ) as $c ) {
// get the number of unread for the category and its descendents; those with zero unread are excluded in "unread-only" mode
$count = Arsse :: $db -> articleCount ( $user , ( new Context ) -> unread ( true ) -> folder ( $c [ 'id' ]));
if ( ! $unread || $count ) {
$out [] = [
'id' => $c [ 'id' ],
'title' => $c [ 'name' ],
'unread' => $count ,
'is_cat' => true ,
'order_id' => ++ $order ,
];
}
}
} catch ( ExceptionInput $e ) {
// in case of errors (because the category does not exist) return the list so far (which should be empty)
return $out ;
}
}
try {
if ( $cat == self :: CAT_NOT_SPECIAL || $cat == self :: CAT_ALL ) {
// if the "All" or "Not Special" categories were selected this returns all subscription, to any depth
$subs = Arsse :: $db -> subscriptionList ( $user , null , true );
} elseif ( $cat == self :: CAT_UNCATEGORIZED ) {
// the "Uncategorized" special category returns subscriptions in the root, without going deeper
$subs = Arsse :: $db -> subscriptionList ( $user , null , false );
} else {
// other categories return their subscriptions, without going deeper
$subs = Arsse :: $db -> subscriptionList ( $user , $cat , false );
}
} catch ( ExceptionInput $e ) {
// in case of errors (invalid category), return what we have so far
return $out ;
}
// append subscriptions to the output
$order = 0 ;
$count = 0 ;
foreach ( $subs as $s ) {
$order ++ ;
if ( $unread && ! $s [ 'unread' ]) {
// ignore any subscriptions with zero unread in "unread-only" mode
continue ;
} elseif ( $offset > 0 ) {
// skip as many subscriptions as necessary to remove any requested offset
$offset -- ;
continue ;
} elseif ( $limit && $count >= $limit ) {
// if we've reached the requested limit, stop
// NOTE: TT-RSS blindly accepts negative limits and returns an empty array
break ;
}
// otherwise, append the subscription
$out [] = [
'id' => $s [ 'id' ],
'title' => $s [ 'title' ],
'unread' => $s [ 'unread' ],
'cat_id' => ( int ) $s [ 'folder' ],
'feed_url' => $s [ 'url' ],
'has_icon' => ( bool ) $s [ 'favicon' ],
'last_updated' => ( int ) Date :: transform ( $s [ 'updated' ], " unix " , " sql " ),
'order_id' => $order ,
];
$count ++ ;
}
return $out ;
}
2017-10-03 14:43:09 +00:00
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 ()) {
2017-10-20 23:02:42 +00:00
case 10502 : // invalid URL
2017-10-03 14:43:09 +00:00
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 {
2017-10-20 13:54:08 +00:00
if ( ! $data [ 'feed_url' ] || ! ValueInfo :: id ( $data [ 'category_id' ], true )) {
// if the feed URL or the category ID is invalid, throw an error
2017-10-03 14:43:09 +00:00
throw new Exception ( " INCORRECT_USAGE " );
}
$url = ( string ) $data [ 'feed_url' ];
2017-10-20 13:54:08 +00:00
$folder = ( int ) $data [ 'category_id' ];
$fetchUser = ( string ) $data [ 'login' ];
$fetchPassword = ( string ) $data [ 'password' ];
2017-10-03 14:43:09 +00:00
// 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 );
2017-10-20 23:02:42 +00:00
} catch ( FeedException $e ) {
2017-10-03 14:43:09 +00:00
// 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 ];
}
2017-10-01 02:15:55 +00:00
public function opUnsubscribeFeed ( array $data ) : array {
try {
// attempt to remove the feed
Arsse :: $db -> subscriptionRemove ( Arsse :: $user -> id , ( int ) $data [ 'feed_id' ]);
2017-10-20 23:02:42 +00:00
} catch ( ExceptionInput $e ) {
2017-10-01 02:15:55 +00:00
throw new Exception ( " FEED_NOT_FOUND " );
}
return [ 'status' => " OK " ];
}
public function opMoveFeed ( array $data ) {
2017-10-20 13:54:08 +00:00
if ( ! ValueInfo :: id ( $data [ 'feed_id' ]) || ! isset ( $data [ 'category_id' ]) || ! ValueInfo :: id ( $data [ 'category_id' ], true )) {
2017-10-01 02:15:55 +00:00
// if the feed or folder is invalid, throw an error
throw new Exception ( " INCORRECT_USAGE " );
}
$in = [
2017-10-20 13:54:08 +00:00
'folder' => $data [ 'category_id' ],
2017-10-01 02:15:55 +00:00
];
try {
// try to move the feed
2017-10-20 13:54:08 +00:00
Arsse :: $db -> subscriptionPropertiesSet ( Arsse :: $user -> id , $data [ 'feed_id' ], $in );
2017-10-20 23:02:42 +00:00
} catch ( ExceptionInput $e ) {
2017-10-01 02:15:55 +00:00
// ignore all errors
}
return null ;
}
public function opRenameFeed ( array $data ) {
$info = ValueInfo :: str ( $data [ 'caption' ]);
2017-10-20 13:54:08 +00:00
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
2017-10-01 02:15:55 +00:00
throw new Exception ( " INCORRECT_USAGE " );
}
$in = [
2017-10-20 13:54:08 +00:00
'name' => $data [ 'caption' ],
2017-10-01 02:15:55 +00:00
];
try {
// try to rename the feed
2017-10-20 13:54:08 +00:00
Arsse :: $db -> subscriptionPropertiesSet ( Arsse :: $user -> id , $data [ 'feed_id' ], $in );
2017-10-20 23:02:42 +00:00
} catch ( ExceptionInput $e ) {
2017-10-01 02:15:55 +00:00
// ignore all errors
}
return null ;
}
2017-10-03 16:43:46 +00:00
2017-10-03 20:14:37 +00:00
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 {
2017-10-20 13:54:08 +00:00
Arsse :: $db -> feedUpdate ( Arsse :: $db -> subscriptionPropertiesGet ( Arsse :: $user -> id , $data [ 'feed_id' ])[ 'feed' ]);
2017-10-20 23:02:42 +00:00
} catch ( ExceptionInput $e ) {
2017-10-03 20:14:37 +00:00
throw new Exception ( " FEED_NOT_FOUND " );
}
return [ 'status' => " OK " ];
}
2017-10-05 21:42:12 +00:00
2017-10-31 03:18:43 +00:00
protected function labelIn ( $id , bool $throw = true ) : int {
2017-10-11 16:55:50 +00:00
if ( ! ( ValueInfo :: int ( $id ) & ValueInfo :: NEG ) || $id > ( - 1 - self :: LABEL_OFFSET )) {
2017-10-31 03:18:43 +00:00
if ( $throw ) {
throw new Exception ( " INCORRECT_USAGE " );
} else {
return 0 ;
}
2017-10-05 21:42:12 +00:00
}
2017-10-11 16:55:50 +00:00
return ( abs ( $id ) - self :: LABEL_OFFSET );
2017-10-05 21:42:12 +00:00
}
protected function labelOut ( int $id ) : int {
2017-10-11 16:55:50 +00:00
return ( $id * - 1 - self :: LABEL_OFFSET );
2017-10-05 21:42:12 +00:00
}
2017-10-13 21:05:06 +00:00
public function opGetLabels ( array $data ) : array {
// this function doesn't complain about invalid article IDs
2017-10-20 13:54:08 +00:00
$article = ValueInfo :: id ( $data [ 'article_id' ]) ? $data [ 'article_id' ] : 0 ;
2017-10-13 21:05:06 +00:00
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 ),
];
2017-10-20 23:02:42 +00:00
}
2017-10-13 21:05:06 +00:00
return $out ;
}
2017-10-05 21:42:12 +00:00
public function opAddLabel ( array $data ) {
$in = [
2017-10-20 13:54:08 +00:00
'name' => ( string ) $data [ 'caption' ],
2017-10-05 21:42:12 +00:00
];
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
2017-10-20 13:54:08 +00:00
$id = $this -> labelIn ( $data [ 'label_id' ]);
2017-10-05 21:42:12 +00:00
try {
// attempt to remove the label
Arsse :: $db -> labelRemove ( Arsse :: $user -> id , $id );
2017-10-20 23:02:42 +00:00
} catch ( ExceptionInput $e ) {
2017-10-05 21:42:12 +00:00
// ignore all errors
}
return null ;
}
public function opRenameLabel ( array $data ) {
// normalize input; missing or invalid IDs are rejected
2017-10-20 13:54:08 +00:00
$id = $this -> labelIn ( $data [ 'label_id' ]);
$name = ( string ) $data [ 'caption' ];
2017-10-05 21:42:12 +00:00
try {
// try to rename the folder
Arsse :: $db -> labelPropertiesSet ( Arsse :: $user -> id , $id , [ 'name' => $name ]);
2017-10-20 23:02:42 +00:00
} catch ( ExceptionInput $e ) {
2017-10-05 21:42:12 +00:00
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 ;
}
2017-10-15 16:47:07 +00:00
public function opSetArticleLabel ( array $data ) : array {
$label = $this -> labelIn ( $data [ 'label_id' ]);
2017-10-28 14:52:38 +00:00
$articles = explode ( " , " , ( string ) $data [ 'article_ids' ]);
2017-10-20 13:54:08 +00:00
$assign = $data [ 'assign' ] ? ? false ;
2017-10-20 22:17:47 +00:00
$out = 0 ;
2017-10-28 14:52:38 +00:00
$in = array_chunk ( $articles , 50 );
2017-10-20 22:17:47 +00:00
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 ];
2017-10-15 16:47:07 +00:00
}
2017-10-31 03:18:43 +00:00
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
2017-10-31 22:12:50 +00:00
$list = array_column ( Arsse :: $db -> subscriptionList ( Arsse :: $user -> id , null , false ) -> getAll (), " id " );
2017-10-31 03:18:43 +00:00
// perform marking for each applicable subscription
foreach ( $list as $id ) {
Arsse :: $db -> articleMark ( Arsse :: $user -> id , [ 'read' => true ], ( new Context ) -> subscription ( $id ));
}
$tr -> commit ();
2017-10-31 22:12:50 +00:00
} catch ( ExceptionInput $e ) { // @codeCoverageIgnore
// ignore errors; none should occur
2017-10-31 03:18:43 +00:00
}
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 ) {
2017-10-31 13:39:06 +00:00
Arsse :: $db -> articleMark ( Arsse :: $user -> id , [ 'read' => true ], ( new Context ) -> label ( $id ));
2017-10-31 03:18:43 +00:00
}
$tr -> commit ();
2017-10-31 22:12:50 +00:00
} catch ( ExceptionInput $e ) { // @codeCoverageIgnore
// ignore errors; none should occur
2017-10-31 03:18:43 +00:00
}
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 ;
}
2017-09-28 14:16:24 +00:00
}