diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 5a575c3..b6696c9 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -46,6 +46,7 @@ abstract class AbstractException extends \Exception { "Db/Exception.savepointStale" => 10227, "Db/Exception.resultReused" => 10228, "Db/ExceptionRetry.schemaChange" => 10229, + "Db/ExceptionInput.invalidValue" => 10230, "Db/ExceptionInput.missing" => 10231, "Db/ExceptionInput.whitespace" => 10232, "Db/ExceptionInput.tooLong" => 10233, diff --git a/lib/Database.php b/lib/Database.php index d60a028..255c2ac 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -754,6 +754,28 @@ class Database { } /** Lists a user's subscriptions, returning various data + * + * Each record has the following keys: + * + * - "id": The numeric identifier of the subscription + * - "feed": The numeric identifier of the underlying newsfeed + * - "url": The URL of the newsfeed, after discovery and HTTP redirects + * - "title": The title of the newsfeed + * - "source": The URL of the source of the newsfeed i.e. its parent Web site + * - "favicon": The URL of an icon representing the newsfeed or its source + * - "folder": The numeric identifier (or null) of the subscription's folder + * - "top_folder": The numeric identifier (or null) of the top-level folder for the subscription + * - "pinned": Whether the subscription is pinned + * - "err_count": The count of times attempting to refresh the newsfeed has resulted in an error since the last successful retrieval + * - "err_msg": The error message of the last unsuccessful retrieval + * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0) + * - "keep_rule": The subscription's "keep" filter rule; articles which do not match this are hidden + * - "block_rule": The subscription's "block" filter rule; articles which match this are hidden + * - "added": The date and time at which the subscription was added + * - "updated": The date and time at which the newsfeed was last updated in the database + * - "edited": The date and time at which the newsfeed was last modified by its authors + * - "modified": The date and time at which the subscription properties were last changed by the user + * - "unread": The number of unread articles associated with the subscription * * @param string $user The user whose subscriptions are to be listed * @param integer|null $folder The identifier of the folder under which to list subscriptions; by default the root folder is used @@ -817,7 +839,11 @@ class Database { return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues()); } - /** Returns the number of subscriptions in a folder, counting recursively */ + /** Returns the number of subscriptions in a folder, counting recursively + * + * @param string $user The user whose subscriptions are to be counted + * @param integer|null $folder The identifier of the folder under which to count subscriptions; by default the root folder is used + */ public function subscriptionCount(string $user, $folder = null): int { // validate inputs $folder = $this->folderValidateId($user, $folder)['id']; @@ -851,24 +877,7 @@ class Database { return true; } - /** Retrieves data about a particular subscription, as an associative array with the following keys: - * - * - "id": The numeric identifier of the subscription - * - "feed": The numeric identifier of the underlying newsfeed - * - "url": The URL of the newsfeed, after discovery and HTTP redirects - * - "title": The title of the newsfeed - * - "favicon": The URL of an icon representing the newsfeed or its source - * - "source": The URL of the source of the newsfeed i.e. its parent Web site - * - "folder": The numeric identifier (or null) of the subscription's folder - * - "top_folder": The numeric identifier (or null) of the top-level folder for the subscription - * - "pinned": Whether the subscription is pinned - * - "err_count": The count of times attempting to refresh the newsfeed has resulted in an error since the last successful retrieval - * - "err_msg": The error message of the last unsuccessful retrieval - * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0) - * - "added": The date and time at which the subscription was added - * - "updated": The date and time at which the newsfeed was last updated (not when it was last refreshed) - * - "unread": The number of unread articles associated with the subscription - */ + /** Retrieves data about a particular subscription, as an associative array; see subscriptionList for details */ public function subscriptionPropertiesGet(string $user, $id): array { if (!V::id($id)) { throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'type' => "int > 0"]); @@ -884,10 +893,12 @@ class Database { * * The $data array must contain one or more of the following keys: * - * - "title": The title of the newsfeed + * - "title": The title of the subscription * - "folder": The numeric identifier (or null) of the subscription's folder * - "pinned": Whether the subscription is pinned * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0) + * - "keep_rule": The subscription's "keep" filter rule; articles which do not match this are hidden + * - "block_rule": The subscription's "block" filter rule; articles which match this are hidden * * @param string $user The user whose subscription is to be modified * @param integer $id the numeric identifier of the subscription to modfify @@ -896,29 +907,45 @@ class Database { public function subscriptionPropertiesSet(string $user, $id, array $data): bool { $tr = $this->db->begin(); // validate the ID - $id = $this->subscriptionValidateId($user, $id, true)['id']; + $id = (int) $this->subscriptionValidateId($user, $id, true)['id']; if (array_key_exists("folder", $data)) { // ensure the target folder exists and belong to the user $data['folder'] = $this->folderValidateId($user, $data['folder'])['id']; } - if (array_key_exists("title", $data)) { + if (isset($data['title'])) { // if the title is null, this signals intended use of the default title; otherwise make sure it's not effectively an empty string - if (!is_null($data['title'])) { - $info = V::str($data['title']); - if ($info & V::EMPTY) { - throw new Db\ExceptionInput("missing", ["action" => __FUNCTION__, "field" => "title"]); - } elseif ($info & V::WHITE) { - throw new Db\ExceptionInput("whitespace", ["action" => __FUNCTION__, "field" => "title"]); - } elseif (!($info & V::VALID)) { - throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "title", 'type' => "string"]); - } + $info = V::str($data['title']); + if ($info & V::EMPTY) { + throw new Db\ExceptionInput("missing", ["action" => __FUNCTION__, "field" => "title"]); + } elseif ($info & V::WHITE) { + throw new Db\ExceptionInput("whitespace", ["action" => __FUNCTION__, "field" => "title"]); + } elseif (!($info & V::VALID)) { + throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "title", 'type' => "string"]); } } + // validate any filter rules + if (isset($data['keep_rule'])) { + if (!is_string($data['keep_rule'])) { + throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "keep_rule", 'type' => "string"]); + } elseif (!Rule::validate($data['keep_rule'])) { + throw new Db\ExceptionInput("invalidValue", ["action" => __FUNCTION__, "field" => "keep_rule"]); + } + } + if (isset($data['block_rule'])) { + if (!is_string($data['block_rule'])) { + throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "block_rule", 'type' => "string"]); + } elseif (!Rule::validate($data['block_rule'])) { + throw new Db\ExceptionInput("invalidValue", ["action" => __FUNCTION__, "field" => "block_rule"]); + } + } + // perform the update $valid = [ 'title' => "str", 'folder' => "int", 'order_type' => "strict int", 'pinned' => "strict bool", + 'keep_rule' => "str", + 'block_rule' => "str", ]; [$setClause, $setTypes, $setValues] = $this->generateSet($data, $valid); if (!$setClause) { @@ -927,6 +954,10 @@ class Database { } $out = (bool) $this->db->prepare("UPDATE arsse_subscriptions set $setClause, modified = CURRENT_TIMESTAMP where owner = ? and id = ?", $setTypes, "str", "int")->run($setValues, $user, $id)->changes(); $tr->commit(); + // if filter rules were changed, apply them + if (array_key_exists("keep_rule", $data) || array_key_exists("block_rule", $data)) { + $this->subscriptionRulesApply($user, $id); + } return $out; } @@ -984,6 +1015,45 @@ class Database { return V::normalize($out, V::T_DATE | V::M_NULL, "sql"); } + /** Evalutes the filter rules specified for a subscription against every article associated with the subscription's feed + * + * @param string $user The user who owns the subscription + * @param integer $id The identifier of the subscription whose rules are to be evaluated + */ + protected function subscriptionRulesApply(string $user, int $id): void { + $sub = $this->db->prepare("SELECT feed, coalesce(keep_rule, '') as keep, coalesce(block_rule, '') as block from arsse_subscriptions where owner = ? and id = ?", "str", "int")->run($user, $id)->getRow(); + try { + $keep = Rule::prep($sub['keep']); + $block = Rule::prep($sub['block']); + $feed = $sub['feed']; + } catch (RuleException $e) { + // invalid rules should not normally appear in the database, but it's possible + // in this case we should halt evaluation and just leave things as they are + return; + } + $articles = $this->db->prepare("SELECT id, title, coalesce(categories, 0) as categories from arsse_articles as a join (select article, count(*) as categories from arsse_categories group by article) as c on a.id = c.article where a.feed = ?", "int")->run($feed)->getAll(); + $hide = []; + $unhide = []; + foreach ($articles as $r) { + // retrieve the list of categories if the article has any + $categories = $r['categories'] ? $this->articleCategoriesGet($user, $r['id']) : []; + // evaluate the rule for the article + if (Rule::apply($keep, $block, $r['title'], $categories)) { + $unhide[] = $r['id']; + } else { + $hide[] = $r['id']; + } + } + // apply any marks + if ($hide) { + $this->articleMark($user, ['hidden' => true], (new Context)->articles($hide), false); + } + if ($unhide) { + $this->articleMark($user, ['hidden' => false], (new Context)->articles($unhide), false); + } + } + + /** Ensures the specified subscription exists and raises an exception otherwise * * Returns an associative array containing the id of the subscription and the id of the underlying newsfeed diff --git a/locale/en.php b/locale/en.php index 1927eaf..b809895 100644 --- a/locale/en.php +++ b/locale/en.php @@ -138,6 +138,7 @@ return [ // indicates programming error 'Exception.JKingWeb/Arsse/Db/Exception.resultReused' => 'Result set already iterated', 'Exception.JKingWeb/Arsse/Db/ExceptionRetry.schemaChange' => '{0}', + 'Exception.JKingWeb/Arsse/Db/ExceptionInput.invalidValue' => 'Value of field "{field}" of action "{action}" is invalid', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.missing' => 'Required field "{field}" missing while performing action "{action}"', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.whitespace' => 'Field "{field}" of action "{action}" may not contain only whitespace', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.tooLong' => 'Field "{field}" of action "{action}" has a maximum length of {max}', diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php index 7fe700a..4ae8343 100644 --- a/tests/cases/Database/SeriesSubscription.php +++ b/tests/cases/Database/SeriesSubscription.php @@ -77,12 +77,14 @@ trait SeriesSubscription { 'folder' => "int", 'pinned' => "bool", 'order_type' => "int", + 'keep_rule' => "str", + 'block_rule' => "str", ], 'rows' => [ - [1,"john.doe@example.com",2,null,null,1,2], - [2,"jane.doe@example.com",2,null,null,0,0], - [3,"john.doe@example.com",3,"Ook",2,0,1], - [4,"jill.doe@example.com",2,null,null,0,0], + [1,"john.doe@example.com",2,null,null,1,2,null,null], + [2,"jane.doe@example.com",2,null,null,0,0,null,null], + [3,"john.doe@example.com",3,"Ook",2,0,1,null,null], + [4,"jill.doe@example.com",2,null,null,0,0,null,null], ], ], 'arsse_tags' => [ @@ -369,17 +371,21 @@ trait SeriesSubscription { 'folder' => 3, 'pinned' => false, 'order_type' => 0, + 'keep_rule' => "ook", + 'block_rule' => "eek", ]); $state = $this->primeExpectations($this->data, [ 'arsse_feeds' => ['id','url','username','password','title'], - 'arsse_subscriptions' => ['id','owner','feed','title','folder','pinned','order_type'], + 'arsse_subscriptions' => ['id','owner','feed','title','folder','pinned','order_type','keep_rule','block_rule'], ]); - $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,"Ook Ook",3,0,0]; + $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,"Ook Ook",3,0,0,"ook","eek"]; $this->compareExpectations(static::$drv, $state); Arsse::$db->subscriptionPropertiesSet($this->user, 1, [ - 'title' => null, + 'title' => null, + 'keep_rule' => null, + 'block_rule' => null, ]); - $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0]; + $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0,null,null]; $this->compareExpectations(static::$drv, $state); // making no changes is a valid result Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['unhinged' => true]); @@ -395,30 +401,28 @@ trait SeriesSubscription { $this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 3, ['folder' => null])); } - public function testRenameASubscriptionToABlankTitle(): void { - $this->assertException("missing", "Db", "ExceptionInput"); - Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => ""]); + /** @dataProvider provideInvalidSubscriptionProperties */ + public function testSetThePropertiesOfASubscriptionToInvalidValues(array $data, string $exp): void { + $this->assertException($exp, "Db", "ExceptionInput"); + Arsse::$db->subscriptionPropertiesSet($this->user, 1, $data); } - public function testRenameASubscriptionToAWhitespaceTitle(): void { - $this->assertException("whitespace", "Db", "ExceptionInput"); - Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => " "]); - } - - public function testRenameASubscriptionToFalse(): void { - $this->assertException("typeViolation", "Db", "ExceptionInput"); - Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => false]); + public function provideInvalidSubscriptionProperties(): iterable { + return [ + 'Empty title' => [['title' => ""], "missing"], + 'Whitespace title' => [['title' => " "], "whitespace"], + 'Non-string title' => [['title' => []], "typeViolation"], + 'Non-string keep rule' => [['keep_rule' => 0], "typeViolation"], + 'Invalid keep rule' => [['keep_rule' => "*"], "invalidValue"], + 'Non-string block rule' => [['block_rule' => 0], "typeViolation"], + 'Invalid block rule' => [['block_rule' => "*"], "invalidValue"], + ]; } public function testRenameASubscriptionToZero(): void { $this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => 0])); } - public function testRenameASubscriptionToAnArray(): void { - $this->assertException("typeViolation", "Db", "ExceptionInput"); - Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => []]); - } - public function testSetThePropertiesOfAMissingSubscription(): void { $this->assertException("subjectMissing", "Db", "ExceptionInput"); Arsse::$db->subscriptionPropertiesSet($this->user, 2112, ['folder' => null]);