Browse Source

Imprement setting of filter rules

rpm
J. King 3 years ago
parent
commit
9f2b8d4f83
  1. 1
      lib/AbstractException.php
  2. 132
      lib/Database.php
  3. 1
      locale/en.php
  4. 52
      tests/cases/Database/SeriesSubscription.php

1
lib/AbstractException.php

@ -46,6 +46,7 @@ abstract class AbstractException extends \Exception {
"Db/Exception.savepointStale" => 10227, "Db/Exception.savepointStale" => 10227,
"Db/Exception.resultReused" => 10228, "Db/Exception.resultReused" => 10228,
"Db/ExceptionRetry.schemaChange" => 10229, "Db/ExceptionRetry.schemaChange" => 10229,
"Db/ExceptionInput.invalidValue" => 10230,
"Db/ExceptionInput.missing" => 10231, "Db/ExceptionInput.missing" => 10231,
"Db/ExceptionInput.whitespace" => 10232, "Db/ExceptionInput.whitespace" => 10232,
"Db/ExceptionInput.tooLong" => 10233, "Db/ExceptionInput.tooLong" => 10233,

132
lib/Database.php

@ -754,6 +754,28 @@ class Database {
} }
/** Lists a user's subscriptions, returning various data /** 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 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 * @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()); 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 { public function subscriptionCount(string $user, $folder = null): int {
// validate inputs // validate inputs
$folder = $this->folderValidateId($user, $folder)['id']; $folder = $this->folderValidateId($user, $folder)['id'];
@ -851,24 +877,7 @@ class Database {
return true; return true;
} }
/** Retrieves data about a particular subscription, as an associative array with the following keys: /** Retrieves data about a particular subscription, as an associative array; see subscriptionList for details */
*
* - "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
*/
public function subscriptionPropertiesGet(string $user, $id): array { public function subscriptionPropertiesGet(string $user, $id): array {
if (!V::id($id)) { if (!V::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'type' => "int > 0"]); 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: * 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 * - "folder": The numeric identifier (or null) of the subscription's folder
* - "pinned": Whether the subscription is pinned * - "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) * - "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 string $user The user whose subscription is to be modified
* @param integer $id the numeric identifier of the subscription to modfify * @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 { public function subscriptionPropertiesSet(string $user, $id, array $data): bool {
$tr = $this->db->begin(); $tr = $this->db->begin();
// validate the ID // validate the ID
$id = $this->subscriptionValidateId($user, $id, true)['id']; $id = (int) $this->subscriptionValidateId($user, $id, true)['id'];
if (array_key_exists("folder", $data)) { if (array_key_exists("folder", $data)) {
// ensure the target folder exists and belong to the user // ensure the target folder exists and belong to the user
$data['folder'] = $this->folderValidateId($user, $data['folder'])['id']; $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 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']);
$info = V::str($data['title']); if ($info & V::EMPTY) {
if ($info & V::EMPTY) { throw new Db\ExceptionInput("missing", ["action" => __FUNCTION__, "field" => "title"]);
throw new Db\ExceptionInput("missing", ["action" => __FUNCTION__, "field" => "title"]); } elseif ($info & V::WHITE) {
} elseif ($info & V::WHITE) { throw new Db\ExceptionInput("whitespace", ["action" => __FUNCTION__, "field" => "title"]);
throw new Db\ExceptionInput("whitespace", ["action" => __FUNCTION__, "field" => "title"]); } elseif (!($info & V::VALID)) {
} elseif (!($info & V::VALID)) { throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "title", 'type' => "string"]);
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 = [ $valid = [
'title' => "str", 'title' => "str",
'folder' => "int", 'folder' => "int",
'order_type' => "strict int", 'order_type' => "strict int",
'pinned' => "strict bool", 'pinned' => "strict bool",
'keep_rule' => "str",
'block_rule' => "str",
]; ];
[$setClause, $setTypes, $setValues] = $this->generateSet($data, $valid); [$setClause, $setTypes, $setValues] = $this->generateSet($data, $valid);
if (!$setClause) { 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(); $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(); $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; return $out;
} }
@ -984,6 +1015,45 @@ class Database {
return V::normalize($out, V::T_DATE | V::M_NULL, "sql"); 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 /** 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 * Returns an associative array containing the id of the subscription and the id of the underlying newsfeed

1
locale/en.php

@ -138,6 +138,7 @@ return [
// indicates programming error // indicates programming error
'Exception.JKingWeb/Arsse/Db/Exception.resultReused' => 'Result set already iterated', 'Exception.JKingWeb/Arsse/Db/Exception.resultReused' => 'Result set already iterated',
'Exception.JKingWeb/Arsse/Db/ExceptionRetry.schemaChange' => '{0}', '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.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.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}', 'Exception.JKingWeb/Arsse/Db/ExceptionInput.tooLong' => 'Field "{field}" of action "{action}" has a maximum length of {max}',

52
tests/cases/Database/SeriesSubscription.php

@ -77,12 +77,14 @@ trait SeriesSubscription {
'folder' => "int", 'folder' => "int",
'pinned' => "bool", 'pinned' => "bool",
'order_type' => "int", 'order_type' => "int",
'keep_rule' => "str",
'block_rule' => "str",
], ],
'rows' => [ 'rows' => [
[1,"john.doe@example.com",2,null,null,1,2], [1,"john.doe@example.com",2,null,null,1,2,null,null],
[2,"jane.doe@example.com",2,null,null,0,0], [2,"jane.doe@example.com",2,null,null,0,0,null,null],
[3,"john.doe@example.com",3,"Ook",2,0,1], [3,"john.doe@example.com",3,"Ook",2,0,1,null,null],
[4,"jill.doe@example.com",2,null,null,0,0], [4,"jill.doe@example.com",2,null,null,0,0,null,null],
], ],
], ],
'arsse_tags' => [ 'arsse_tags' => [
@ -369,17 +371,21 @@ trait SeriesSubscription {
'folder' => 3, 'folder' => 3,
'pinned' => false, 'pinned' => false,
'order_type' => 0, 'order_type' => 0,
'keep_rule' => "ook",
'block_rule' => "eek",
]); ]);
$state = $this->primeExpectations($this->data, [ $state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password','title'], '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); $this->compareExpectations(static::$drv, $state);
Arsse::$db->subscriptionPropertiesSet($this->user, 1, [ 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); $this->compareExpectations(static::$drv, $state);
// making no changes is a valid result // making no changes is a valid result
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['unhinged' => true]); Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['unhinged' => true]);
@ -395,30 +401,28 @@ trait SeriesSubscription {
$this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 3, ['folder' => null])); $this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 3, ['folder' => null]));
} }
public function testRenameASubscriptionToABlankTitle(): void { /** @dataProvider provideInvalidSubscriptionProperties */
$this->assertException("missing", "Db", "ExceptionInput"); public function testSetThePropertiesOfASubscriptionToInvalidValues(array $data, string $exp): void {
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => ""]); $this->assertException($exp, "Db", "ExceptionInput");
Arsse::$db->subscriptionPropertiesSet($this->user, 1, $data);
} }
public function testRenameASubscriptionToAWhitespaceTitle(): void { public function provideInvalidSubscriptionProperties(): iterable {
$this->assertException("whitespace", "Db", "ExceptionInput"); return [
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => " "]); 'Empty title' => [['title' => ""], "missing"],
} 'Whitespace title' => [['title' => " "], "whitespace"],
'Non-string title' => [['title' => []], "typeViolation"],
public function testRenameASubscriptionToFalse(): void { 'Non-string keep rule' => [['keep_rule' => 0], "typeViolation"],
$this->assertException("typeViolation", "Db", "ExceptionInput"); 'Invalid keep rule' => [['keep_rule' => "*"], "invalidValue"],
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => false]); 'Non-string block rule' => [['block_rule' => 0], "typeViolation"],
'Invalid block rule' => [['block_rule' => "*"], "invalidValue"],
];
} }
public function testRenameASubscriptionToZero(): void { public function testRenameASubscriptionToZero(): void {
$this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => 0])); $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 { public function testSetThePropertiesOfAMissingSubscription(): void {
$this->assertException("subjectMissing", "Db", "ExceptionInput"); $this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->subscriptionPropertiesSet($this->user, 2112, ['folder' => null]); Arsse::$db->subscriptionPropertiesSet($this->user, 2112, ['folder' => null]);

Loading…
Cancel
Save