mirror of
https://code.mensbeam.com/MensBeam/Arsse.git
synced 2024-12-22 13:12:41 +00:00
Merge branch 'search'
This commit is contained in:
commit
6857e8ec1b
20 changed files with 1062 additions and 297 deletions
|
@ -1,3 +1,9 @@
|
|||
Version 0.7.0 (2019-??-??)
|
||||
==========================
|
||||
|
||||
New features:
|
||||
- Support for basic freeform searching in Tiny Tiny RSS
|
||||
|
||||
Version 0.6.1 (2019-01-23)
|
||||
==========================
|
||||
|
||||
|
|
|
@ -130,7 +130,6 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a
|
|||
- The `getPref` operation is not implemented; it returns `UNKNOWN_METHOD`
|
||||
- The `shareToPublished` operation is not implemented; it returns `UNKNOWN_METHOD`
|
||||
- Setting an article's "published" flag with the `updateArticle` operation is not implemented and will gracefully fail
|
||||
- The `search` parameter of the `getHeadlines` operation is not implemented; the operation will proceed as if no search string were specified
|
||||
- The `sanitize`, `force_update`, and `has_sandbox` parameters of the `getHeadlines` operation are ignored
|
||||
- String `feed_id` values for the `getCompactHeadlines` operation are not supported and will yield an `INCORRECT_USAGE` error
|
||||
- Articles are limited to a single attachment rather than multiple attachments
|
||||
|
@ -141,6 +140,13 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a
|
|||
- Feed, category, and label names are normally unrestricted; The Arsse rejects empty strings, as well as strings composed solely of whitespace
|
||||
- Discovering multiple feeds during `subscribeToFeed` processing normally produces an error; The Arsse instead chooses the first feed it finds
|
||||
- Providing the `setArticleLabel` operation with an invalid label normally silently fails; The Arsse returns an `INVALID_USAGE` error instead
|
||||
- Processing of the `search` parameter of the `getHeadlines` operation differs in the following ways:
|
||||
- Values other than `"true"` or `"false"` for the `unread`, `star`, and `pub` special keywords treat the entire token as a search term rather than as `"false"`
|
||||
- Limits are placed on the number of search terms: ten each for `title`, `author`, and `note`, and twenty for content searching; exceeding the limits will yield a non-standard `TOO_MANY_SEARCH_TERMS` error
|
||||
- Invalid dates are ignored rather than assumed to be `"1970-01-01"`
|
||||
- Only a single negative date is allowed (this is a known bug rather than intentional)
|
||||
- Dates are always relative to UTC
|
||||
- Full-text search is not yet employed with any database, including PostgreSQL
|
||||
- Article hashes are normally SHA1; The Arsse uses SHA256 hashes
|
||||
- Article attachments normally have unique IDs; The Arsse always gives attachments an ID of `"0"`
|
||||
- The default sort order of the `getHeadlines` operation normally uses custom sorting for "special" feeds; The Arsse's default sort order is equivalent to `feed_dates` for all feeds
|
||||
|
|
61
lib/Context/Context.php
Normal file
61
lib/Context/Context.php
Normal file
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
/** @license MIT
|
||||
* Copyright 2017 J. King, Dustin Wilson et al.
|
||||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace JKingWeb\Arsse\Context;
|
||||
|
||||
class Context extends ExclusionContext {
|
||||
/** @var ExclusionContext */
|
||||
public $not;
|
||||
public $reverse = false;
|
||||
public $limit = 0;
|
||||
public $offset = 0;
|
||||
public $unread;
|
||||
public $starred;
|
||||
public $labelled;
|
||||
public $annotated;
|
||||
|
||||
public function __construct() {
|
||||
$this->not = new ExclusionContext($this);
|
||||
}
|
||||
|
||||
public function __clone() {
|
||||
// clone the exclusion context as well
|
||||
$this->not = clone $this->not;
|
||||
}
|
||||
|
||||
/** @codeCoverageIgnore */
|
||||
public function __destruct() {
|
||||
unset($this->not);
|
||||
}
|
||||
|
||||
public function reverse(bool $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function limit(int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function offset(int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function unread(bool $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function starred(bool $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function labelled(bool $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function annotated(bool $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
}
|
|
@ -4,38 +4,54 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace JKingWeb\Arsse\Misc;
|
||||
namespace JKingWeb\Arsse\Context;
|
||||
|
||||
use JKingWeb\Arsse\Misc\Date;
|
||||
use JKingWeb\Arsse\Misc\ValueInfo;
|
||||
use JKingWeb\Arsse\Misc\Date;
|
||||
|
||||
class Context {
|
||||
public $reverse = false;
|
||||
public $limit = 0;
|
||||
public $offset = 0;
|
||||
class ExclusionContext {
|
||||
public $folder;
|
||||
public $folderShallow;
|
||||
public $subscription;
|
||||
public $oldestArticle;
|
||||
public $latestArticle;
|
||||
public $oldestEdition;
|
||||
public $latestEdition;
|
||||
public $unread = null;
|
||||
public $starred = null;
|
||||
public $modifiedSince;
|
||||
public $notModifiedSince;
|
||||
public $markedSince;
|
||||
public $notMarkedSince;
|
||||
public $edition;
|
||||
public $article;
|
||||
public $editions;
|
||||
public $articles;
|
||||
public $label;
|
||||
public $labelName;
|
||||
public $labelled = null;
|
||||
public $annotated = null;
|
||||
public $annotationTerms;
|
||||
public $searchTerms;
|
||||
public $titleTerms;
|
||||
public $authorTerms;
|
||||
public $oldestArticle;
|
||||
public $latestArticle;
|
||||
public $oldestEdition;
|
||||
public $latestEdition;
|
||||
public $modifiedSince;
|
||||
public $notModifiedSince;
|
||||
public $markedSince;
|
||||
public $notMarkedSince;
|
||||
|
||||
protected $props = [];
|
||||
protected $parent;
|
||||
|
||||
public function __construct(self $c = null) {
|
||||
$this->parent = $c;
|
||||
}
|
||||
|
||||
public function __clone() {
|
||||
if ($this->parent) {
|
||||
$t = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1];
|
||||
if (($t['object'] ?? null) instanceof self && $t['function'] === "__clone") {
|
||||
$this->parent = $t['object'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @codeCoverageIgnore */
|
||||
public function __destruct() {
|
||||
unset($this->parent);
|
||||
}
|
||||
|
||||
protected function act(string $prop, int $set, $value) {
|
||||
if ($set) {
|
||||
|
@ -46,13 +62,13 @@ class Context {
|
|||
$this->props[$prop] = true;
|
||||
$this->$prop = $value;
|
||||
}
|
||||
return $this;
|
||||
return $this->parent ?? $this;
|
||||
} else {
|
||||
return isset($this->props[$prop]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function cleanArray(array $spec): array {
|
||||
protected function cleanIdArray(array $spec): array {
|
||||
$spec = array_values($spec);
|
||||
for ($a = 0; $a < sizeof($spec); $a++) {
|
||||
if (ValueInfo::id($spec[$a])) {
|
||||
|
@ -61,19 +77,20 @@ class Context {
|
|||
$spec[$a] = 0;
|
||||
}
|
||||
}
|
||||
return array_values(array_filter($spec));
|
||||
return array_values(array_unique(array_filter($spec)));
|
||||
}
|
||||
|
||||
public function reverse(bool $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function limit(int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function offset(int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
protected function cleanStringArray(array $spec): array {
|
||||
$spec = array_values($spec);
|
||||
$stop = sizeof($spec);
|
||||
for ($a = 0; $a < $stop; $a++) {
|
||||
if (strlen($str = ValueInfo::normalize($spec[$a], ValueInfo::T_STRING | ValueInfo::M_DROP) ?? "")) {
|
||||
$spec[$a] = $str;
|
||||
} else {
|
||||
unset($spec[$a]);
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($spec));
|
||||
}
|
||||
|
||||
public function folder(int $spec = null) {
|
||||
|
@ -88,6 +105,64 @@ class Context {
|
|||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function edition(int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function article(int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function editions(array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanIdArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function articles(array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanIdArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function label(int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function labelName(string $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function annotationTerms(array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanStringArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function searchTerms(array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanStringArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function titleTerms(array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanStringArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function authorTerms(array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanStringArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function latestArticle(int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
@ -104,14 +179,6 @@ class Context {
|
|||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function unread(bool $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function starred(bool $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function modifiedSince($spec = null) {
|
||||
$spec = Date::normalize($spec);
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
|
@ -131,42 +198,4 @@ class Context {
|
|||
$spec = Date::normalize($spec);
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function edition(int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function article(int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function editions(array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function articles(array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function label(int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function labelName(string $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function labelled(bool $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function annotated(bool $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
}
|
311
lib/Database.php
311
lib/Database.php
|
@ -9,7 +9,8 @@ namespace JKingWeb\Arsse;
|
|||
use JKingWeb\DrUUID\UUID;
|
||||
use JKingWeb\Arsse\Db\Statement;
|
||||
use JKingWeb\Arsse\Misc\Query;
|
||||
use JKingWeb\Arsse\Misc\Context;
|
||||
use JKingWeb\Arsse\Context\Context;
|
||||
use JKingWeb\Arsse\Context\ExclusionContext;
|
||||
use JKingWeb\Arsse\Misc\Date;
|
||||
use JKingWeb\Arsse\Misc\ValueInfo;
|
||||
|
||||
|
@ -127,7 +128,7 @@ class Database {
|
|||
|
||||
/** Conputes the contents of an SQL "IN()" clause, producing one parameter placeholder for each input value
|
||||
*
|
||||
* Returns an indexed array containing the clause text, and an array of types
|
||||
* Returns an indexed array containing the clause text, an array of types, and the array of values
|
||||
*
|
||||
* @param array $values Arbitrary values
|
||||
* @param string $type A single data type applied to each value
|
||||
|
@ -136,6 +137,7 @@ class Database {
|
|||
$out = [
|
||||
"", // query clause
|
||||
[], // binding types
|
||||
$values, // binding values
|
||||
];
|
||||
if (sizeof($values)) {
|
||||
// the query clause is just a series of question marks separated by commas
|
||||
|
@ -149,6 +151,37 @@ class Database {
|
|||
return $out;
|
||||
}
|
||||
|
||||
/** Computes basic LIKE-based text search constraints for use in a WHERE clause
|
||||
*
|
||||
* Returns an indexed array containing the clause text, an array of types, and another array of values
|
||||
*
|
||||
* The clause is structured such that all terms must be present across any of the columns
|
||||
*
|
||||
* @param string[] $terms The terms to search for
|
||||
* @param string[] $cols The columns to match against; these are -not- sanitized, so much -not- come directly from user input
|
||||
* @param boolean $matchAny Whether the search is successful when it matches any (true) or all (false) terms
|
||||
*/
|
||||
protected function generateSearch(array $terms, array $cols, bool $matchAny = false): array {
|
||||
$clause = [];
|
||||
$types = [];
|
||||
$values = [];
|
||||
$like = $this->db->sqlToken("like");
|
||||
foreach($terms as $term) {
|
||||
$term = str_replace(["%", "_", "^"], ["^%", "^_", "^^"], $term);
|
||||
$term = "%$term%";
|
||||
$spec = [];
|
||||
foreach ($cols as $col) {
|
||||
$spec[] = "$col $like ? escape '^'";
|
||||
$types[] = "str";
|
||||
$values[] = $term;
|
||||
}
|
||||
$clause[] = "(".implode(" or ", $spec).")";
|
||||
}
|
||||
$glue = $matchAny ? "or" : "and";
|
||||
$clause = "(".implode(" $glue ", $clause).")";
|
||||
return [$clause, $types, $values];
|
||||
}
|
||||
|
||||
/** Returns a Transaction object, which is rolled back unless explicitly committed */
|
||||
public function begin(): Db\Transaction {
|
||||
return $this->db->begin();
|
||||
|
@ -351,7 +384,7 @@ class Database {
|
|||
*
|
||||
* @param string $uer The user whose folders are to be listed
|
||||
* @param integer|null $parent Restricts the list to the descendents of the specified folder identifier
|
||||
* @param boolean $recursive Whether to list all descendents, or only direct children
|
||||
* @param boolean $recursive Whether to list all descendents (true) or only direct children (false)
|
||||
*/
|
||||
public function folderList(string $user, $parent = null, bool $recursive = true): Db\Result {
|
||||
// if the user isn't authorized to perform this action then throw an exception.
|
||||
|
@ -469,7 +502,7 @@ class Database {
|
|||
*
|
||||
* @param string $user The user who owns the folder to be validated
|
||||
* @param integer|null $id The identifier of the folder to validate; null or zero represent the implied root folder
|
||||
* @param boolean $subject Whether the folder is the subject rather than the object of the operation being performed; this only affects the semantics of the error message if validation fails
|
||||
* @param boolean $subject Whether the folder is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails
|
||||
*/
|
||||
protected function folderValidateId(string $user, $id = null, bool $subject = false): array {
|
||||
// if the specified ID is not a non-negative integer (or null), this will always fail
|
||||
|
@ -808,7 +841,7 @@ class Database {
|
|||
*
|
||||
* @param string $user The user who owns the subscription to be validated
|
||||
* @param integer|null $id The identifier of the subscription to validate
|
||||
* @param boolean $subject Whether the subscription is the subject rather than the object of the operation being performed; this only affects the semantics of the error message if validation fails
|
||||
* @param boolean $subject Whether the subscription is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails
|
||||
*/
|
||||
protected function subscriptionValidateId(string $user, $id, bool $subject = false): array {
|
||||
if (!ValueInfo::id($id)) {
|
||||
|
@ -1065,8 +1098,30 @@ class Database {
|
|||
* @param array $cols The columns to request in the result set
|
||||
*/
|
||||
protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query {
|
||||
// validate input
|
||||
if ($context->subscription()) {
|
||||
$this->subscriptionValidateId($user, $context->subscription);
|
||||
}
|
||||
if ($context->folder()) {
|
||||
$this->folderValidateId($user, $context->folder);
|
||||
}
|
||||
if ($context->folderShallow()) {
|
||||
$this->folderValidateId($user, $context->folderShallow);
|
||||
}
|
||||
if ($context->edition()) {
|
||||
$this->articleValidateEdition($user, $context->edition);
|
||||
}
|
||||
if ($context->article()) {
|
||||
$this->articleValidateId($user, $context->article);
|
||||
}
|
||||
if ($context->label()) {
|
||||
$this->labelValidateId($user, $context->label, false);
|
||||
}
|
||||
if ($context->labelName()) {
|
||||
$this->labelValidateId($user, $context->labelName, true);
|
||||
}
|
||||
// prepare the output column list; the column definitions are also used later
|
||||
$greatest = $this->db->sqlToken("greatest");
|
||||
// prepare the output column list
|
||||
$colDefs = [
|
||||
'id' => "arsse_articles.id",
|
||||
'edition' => "latest_editions.edition",
|
||||
|
@ -1076,6 +1131,7 @@ class Database {
|
|||
'content' => "arsse_articles.content",
|
||||
'guid' => "arsse_articles.guid",
|
||||
'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash",
|
||||
'folder' => "coalesce(arsse_subscriptions.folder,0)",
|
||||
'subscription' => "arsse_subscriptions.id",
|
||||
'feed' => "arsse_subscriptions.feed",
|
||||
'starred' => "coalesce(arsse_marks.starred,0)",
|
||||
|
@ -1084,7 +1140,7 @@ class Database {
|
|||
'published_date' => "arsse_articles.published",
|
||||
'edited_date' => "arsse_articles.edited",
|
||||
'modified_date' => "arsse_articles.modified",
|
||||
'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(arsse_label_members.modified, '0001-01-01 00:00:00'))",
|
||||
'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(label_stats.modified, '0001-01-01 00:00:00'))",
|
||||
'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)",
|
||||
'media_url' => "arsse_enclosures.url",
|
||||
'media_type' => "arsse_enclosures.type",
|
||||
|
@ -1112,115 +1168,152 @@ class Database {
|
|||
join arsse_feeds on arsse_subscriptions.feed = arsse_feeds.id
|
||||
left join arsse_marks on arsse_marks.subscription = arsse_subscriptions.id and arsse_marks.article = arsse_articles.id
|
||||
left join arsse_enclosures on arsse_enclosures.article = arsse_articles.id
|
||||
left join arsse_label_members on arsse_label_members.subscription = arsse_subscriptions.id and arsse_label_members.article = arsse_articles.id and arsse_label_members.assigned = 1
|
||||
left join arsse_labels on arsse_labels.owner = arsse_subscriptions.owner and arsse_label_members.label = arsse_labels.id",
|
||||
["str"],
|
||||
[$user]
|
||||
join (
|
||||
SELECT article, max(id) as edition from arsse_editions group by article
|
||||
) as latest_editions on arsse_articles.id = latest_editions.article
|
||||
left join (
|
||||
SELECT arsse_label_members.article, max(arsse_label_members.modified) as modified, sum(arsse_label_members.assigned) as assigned from arsse_label_members join arsse_labels on arsse_labels.id = arsse_label_members.label where arsse_labels.owner = ? group by arsse_label_members.article
|
||||
) as label_stats on label_stats.article = arsse_articles.id",
|
||||
["str", "str"],
|
||||
[$user, $user]
|
||||
);
|
||||
$q->setCTE("latest_editions(article,edition)", "SELECT article,max(id) from arsse_editions group by article", [], [], "join latest_editions on arsse_articles.id = latest_editions.article");
|
||||
if ($cols) {
|
||||
// if there are no output columns requested we're getting a count and should not group, but otherwise we should
|
||||
$q->setGroup("arsse_articles.id", "arsse_marks.note", "arsse_enclosures.url", "arsse_enclosures.type", "arsse_subscriptions.title", "arsse_feeds.title", "arsse_subscriptions.id", "arsse_marks.modified", "arsse_label_members.modified", "arsse_marks.read", "arsse_marks.starred", "latest_editions.edition");
|
||||
}
|
||||
$q->setLimit($context->limit, $context->offset);
|
||||
if ($context->subscription()) {
|
||||
// if a subscription is specified, make sure it exists
|
||||
$this->subscriptionValidateId($user, $context->subscription);
|
||||
// filter for the subscription
|
||||
$q->setWhere("arsse_subscriptions.id = ?", "int", $context->subscription);
|
||||
} elseif ($context->folder()) {
|
||||
// if a folder is specified, make sure it exists
|
||||
$this->folderValidateId($user, $context->folder);
|
||||
// if it does exist, add a common table expression to list it and its children so that we select from the entire subtree
|
||||
$q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder);
|
||||
// limit subscriptions to the listed folders
|
||||
$q->setWhere("arsse_subscriptions.folder in (select folder from folders)");
|
||||
} elseif ($context->folderShallow()) {
|
||||
// if a shallow folder is specified, make sure it exists
|
||||
$this->folderValidateId($user, $context->folderShallow);
|
||||
// if it does exist, filter for that folder only
|
||||
$q->setWhere("coalesce(arsse_subscriptions.folder,0) = ?", "int", $context->folderShallow);
|
||||
}
|
||||
if ($context->edition()) {
|
||||
// if an edition is specified, first validate it, then filter for it
|
||||
$this->articleValidateEdition($user, $context->edition);
|
||||
$q->setWhere("latest_editions.edition = ?", "int", $context->edition);
|
||||
} elseif ($context->article()) {
|
||||
// if an article is specified, first validate it, then filter for it
|
||||
$this->articleValidateId($user, $context->article);
|
||||
$q->setWhere("arsse_articles.id = ?", "int", $context->article);
|
||||
}
|
||||
if ($context->editions()) {
|
||||
// if multiple specific editions have been requested, filter against the list
|
||||
if (!$context->editions) {
|
||||
throw new Db\ExceptionInput("tooShort", ['field' => "editions", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element
|
||||
} elseif (sizeof($context->editions) > self::LIMIT_ARTICLES) {
|
||||
throw new Db\ExceptionInput("tooLong", ['field' => "editions", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore
|
||||
}
|
||||
list($inParams, $inTypes) = $this->generateIn($context->editions, "int");
|
||||
$q->setWhere("latest_editions.edition in ($inParams)", $inTypes, $context->editions);
|
||||
} elseif ($context->articles()) {
|
||||
// if multiple specific articles have been requested, prepare a CTE to list them and their articles
|
||||
if (!$context->articles) {
|
||||
throw new Db\ExceptionInput("tooShort", ['field' => "articles", 'action' => __FUNCTION__, 'min' => 1]); // must have at least one array element
|
||||
} elseif (sizeof($context->articles) > self::LIMIT_ARTICLES) {
|
||||
throw new Db\ExceptionInput("tooLong", ['field' => "articles", 'action' => __FUNCTION__, 'max' => self::LIMIT_ARTICLES]); // @codeCoverageIgnore
|
||||
}
|
||||
list($inParams, $inTypes) = $this->generateIn($context->articles, "int");
|
||||
$q->setWhere("arsse_articles.id in ($inParams)", $inTypes, $context->articles);
|
||||
}
|
||||
// filter based on label by ID or name
|
||||
if ($context->labelled()) {
|
||||
// any label (true) or no label (false)
|
||||
$isOrIsNot = (!$context->labelled ? "is" : "is not");
|
||||
$q->setWhere("arsse_labels.id $isOrIsNot null");
|
||||
} elseif ($context->label() || $context->labelName()) {
|
||||
// specific label ID or name
|
||||
if ($context->label()) {
|
||||
$id = $this->labelValidateId($user, $context->label, false)['id'];
|
||||
// handle the simple context options
|
||||
$options = [
|
||||
// each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, an option to pair with for BETWEEN evaluation, and an upper bound if the value is an array
|
||||
"edition" => ["edition", "=", "int", "", 1],
|
||||
"editions" => ["edition", "in", "int", "", self::LIMIT_ARTICLES],
|
||||
"article" => ["id", "=", "int", "", 1],
|
||||
"articles" => ["id", "in", "int", "", self::LIMIT_ARTICLES],
|
||||
"oldestArticle" => ["id", ">=", "int", "latestArticle", 1],
|
||||
"latestArticle" => ["id", "<=", "int", "oldestArticle", 1],
|
||||
"oldestEdition" => ["edition", ">=", "int", "latestEdition", 1],
|
||||
"latestEdition" => ["edition", "<=", "int", "oldestEdition", 1],
|
||||
"modifiedSince" => ["modified_date", ">=", "datetime", "notModifiedSince", 1],
|
||||
"notModifiedSince" => ["modified_date", "<=", "datetime", "modifiedSince", 1],
|
||||
"markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince", 1],
|
||||
"notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince", 1],
|
||||
"folderShallow" => ["folder", "=", "int", "", 1],
|
||||
"subscription" => ["subscription", "=", "int", "", 1],
|
||||
"unread" => ["unread", "=", "bool", "", 1],
|
||||
"starred" => ["starred", "=", "bool", "", 1],
|
||||
];
|
||||
foreach ($options as $m => list($col, $op, $type, $pair, $max)) {
|
||||
if (!$context->$m()) {
|
||||
// context is not being used
|
||||
continue;
|
||||
} elseif (is_array($context->$m)) {
|
||||
// context option is an array of values
|
||||
if (!$context->$m) {
|
||||
throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element
|
||||
} elseif (sizeof($context->$m) > $max) {
|
||||
throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]); // @codeCoverageIgnore
|
||||
}
|
||||
list($clause, $types, $values) = $this->generateIn($context->$m, $type);
|
||||
$q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values);
|
||||
} elseif ($pair && $context->$pair()) {
|
||||
// option is paired with another which is also being used
|
||||
if ($op === ">=") {
|
||||
$q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->$m, $context->$pair]);
|
||||
} else {
|
||||
// option has already been paired
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
$id = $this->labelValidateId($user, $context->labelName, true)['id'];
|
||||
$q->setWhere("{$colDefs[$col]} $op ?", $type, $context->$m);
|
||||
}
|
||||
$q->setWhere("arsse_labels.id = ?", "int", $id);
|
||||
}
|
||||
// filter based on article or edition offset
|
||||
if ($context->oldestArticle()) {
|
||||
$q->setWhere("arsse_articles.id >= ?", "int", $context->oldestArticle);
|
||||
// further handle exclusionary options if specified
|
||||
foreach ($options as $m => list($col, $op, $type, $pair, $max)) {
|
||||
if (!method_exists($context->not, $m) || !$context->not->$m()) {
|
||||
// context option is not being used
|
||||
continue;
|
||||
} elseif (is_array($context->not->$m)) {
|
||||
if (!$context->not->$m) {
|
||||
// for exclusions we don't care if the array is empty
|
||||
continue;
|
||||
} elseif (sizeof($context->not->$m) > $max) {
|
||||
throw new Db\ExceptionInput("tooLong", ['field' => "$m (not)", 'action' => $this->caller(), 'max' => $max]);
|
||||
}
|
||||
list($clause, $types, $values) = $this->generateIn($context->not->$m, $type);
|
||||
$q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values);
|
||||
} elseif ($pair && $context->not->$pair()) {
|
||||
// option is paired with another which is also being used
|
||||
if ($op === ">=") {
|
||||
$q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->not->$m, $context->not->$pair]);
|
||||
} else {
|
||||
// option has already been paired
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
$q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m);
|
||||
}
|
||||
}
|
||||
if ($context->latestArticle()) {
|
||||
$q->setWhere("arsse_articles.id <= ?", "int", $context->latestArticle);
|
||||
}
|
||||
if ($context->oldestEdition()) {
|
||||
$q->setWhere("latest_editions.edition >= ?", "int", $context->oldestEdition);
|
||||
}
|
||||
if ($context->latestEdition()) {
|
||||
$q->setWhere("latest_editions.edition <= ?", "int", $context->latestEdition);
|
||||
}
|
||||
// filter based on time at which an article was changed by feed updates (modified), or by user action (marked)
|
||||
if ($context->modifiedSince()) {
|
||||
$q->setWhere("arsse_articles.modified >= ?", "datetime", $context->modifiedSince);
|
||||
}
|
||||
if ($context->notModifiedSince()) {
|
||||
$q->setWhere("arsse_articles.modified <= ?", "datetime", $context->notModifiedSince);
|
||||
}
|
||||
if ($context->markedSince()) {
|
||||
$q->setWhere($colDefs['marked_date']." >= ?", "datetime", $context->markedSince);
|
||||
}
|
||||
if ($context->notMarkedSince()) {
|
||||
$q->setWhere($colDefs['marked_date']." <= ?", "datetime", $context->notMarkedSince);
|
||||
}
|
||||
// filter for un/read and un/starred status if specified
|
||||
if ($context->unread()) {
|
||||
$q->setWhere("coalesce(arsse_marks.read,0) = ?", "bool", !$context->unread);
|
||||
}
|
||||
if ($context->starred()) {
|
||||
$q->setWhere("coalesce(arsse_marks.starred,0) = ?", "bool", $context->starred);
|
||||
}
|
||||
// filter based on whether the article has a note
|
||||
// handle complex context options
|
||||
if ($context->annotated()) {
|
||||
$comp = ($context->annotated) ? "<>" : "=";
|
||||
$q->setWhere("coalesce(arsse_marks.note,'') $comp ''");
|
||||
}
|
||||
if ($context->labelled()) {
|
||||
// any label (true) or no label (false)
|
||||
$op = $context->labelled ? ">" : "=";
|
||||
$q->setWhere("coalesce(label_stats.assigned,0) $op 0");
|
||||
}
|
||||
if ($context->label() || $context->not->label() || $context->labelName() || $context->not->labelName()) {
|
||||
$q->setCTE("labelled(article,label_id,label_name)","SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1", "str", $user);
|
||||
if ($context->label()) {
|
||||
$q->setWhere("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->label);
|
||||
}
|
||||
if ($context->not->label()) {
|
||||
$q->setWhereNot("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->not->label);
|
||||
}
|
||||
if ($context->labelName()) {
|
||||
$q->setWhere("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->labelName);
|
||||
}
|
||||
if ($context->not->labelName()) {
|
||||
$q->setWhereNot("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->not->labelName);
|
||||
}
|
||||
}
|
||||
if ($context->folder()) {
|
||||
// add a common table expression to list the folder and its children so that we select from the entire subtree
|
||||
$q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder);
|
||||
// limit subscriptions to the listed folders
|
||||
$q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders)");
|
||||
}
|
||||
if ($context->not->folder()) {
|
||||
// add a common table expression to list the folder and its children so that we exclude from the entire subtree
|
||||
$q->setCTE("folders_excluded(folder)", "SELECT ? union select id from arsse_folders join folders_excluded on parent = folder", "int", $context->not->folder);
|
||||
// excluded any subscriptions in the listed folders
|
||||
$q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_excluded)");
|
||||
}
|
||||
// handle text-matching context options
|
||||
$options = [
|
||||
"titleTerms" => [10, ["arsse_articles.title"]],
|
||||
"searchTerms" => [20, ["arsse_articles.title", "arsse_articles.content"]],
|
||||
"authorTerms" => [10, ["arsse_articles.author"]],
|
||||
"annotationTerms" => [20, ["arsse_marks.note"]],
|
||||
];
|
||||
foreach ($options as $m => list($max, $cols)) {
|
||||
if (!$context->$m()) {
|
||||
continue;
|
||||
} elseif (!$context->$m) {
|
||||
throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element
|
||||
} elseif (sizeof($context->$m) > $max) {
|
||||
throw new Db\ExceptionInput("tooLong", ['field' => $m, 'action' => $this->caller(), 'max' => $max]);
|
||||
}
|
||||
$q->setWhere(...$this->generateSearch($context->$m, $cols));
|
||||
}
|
||||
// further handle exclusionary text-matching context options
|
||||
foreach ($options as $m => list($max, $cols)) {
|
||||
if (!$context->not->$m()) {
|
||||
continue;
|
||||
} elseif (!$context->not->$m) {
|
||||
continue;
|
||||
} elseif (sizeof($context->not->$m) > $max) {
|
||||
throw new Db\ExceptionInput("tooLong", ['field' => "$m (not)", 'action' => $this->caller(), 'max' => $max]);
|
||||
}
|
||||
$q->setWhereNot(...$this->generateSearch($context->not->$m, $cols, true));
|
||||
}
|
||||
// return the query
|
||||
return $q;
|
||||
}
|
||||
|
@ -1257,7 +1350,7 @@ class Database {
|
|||
*
|
||||
* @param string $user The user whose articles are to be listed
|
||||
* @param Context $context The search context
|
||||
* @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type
|
||||
* @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type
|
||||
*/
|
||||
public function articleList(string $user, Context $context = null, array $fields = ["id"]): Db\Result {
|
||||
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
|
||||
|
@ -1436,7 +1529,7 @@ class Database {
|
|||
*
|
||||
* @param string $user The user whose labels are to be listed
|
||||
* @param integer $id The numeric identifier of the article whose labels are to be listed
|
||||
* @param boolean $byName Whether to return the label names instead of the numeric label identifiers
|
||||
* @param boolean $byName Whether to return the label names (true) instead of the numeric label identifiers (false)
|
||||
*/
|
||||
public function articleLabelsGet(string $user, $id, bool $byName = false): array {
|
||||
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
|
||||
|
@ -1836,7 +1929,7 @@ class Database {
|
|||
* @param integer|string $id The numeric identifier or name of the label to validate
|
||||
* @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false)
|
||||
* @param boolean $checkDb Whether to check whether the label exists (true) or only if the identifier or name is syntactically valid (false)
|
||||
* @param boolean $subject Whether the label is the subject rather than the object of the operation being performed; this only affects the semantics of the error message if validation fails
|
||||
* @param boolean $subject Whether the label is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails
|
||||
*/
|
||||
protected function labelValidateId(string $user, $id, bool $byName, bool $checkDb = true, bool $subject = false): array {
|
||||
if (!$byName && !ValueInfo::id($id)) {
|
||||
|
|
|
@ -73,6 +73,7 @@ interface Driver {
|
|||
*
|
||||
* - "greatest": the GREATEST function implemented by PostgreSQL and MySQL
|
||||
* - "nocase": the name of a general-purpose case-insensitive collation sequence
|
||||
* - "like": the case-insensitive LIKE operator
|
||||
*/
|
||||
public function sqlToken(string $token): string;
|
||||
}
|
||||
|
|
|
@ -120,6 +120,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
|
|||
switch (strtolower($token)) {
|
||||
case "nocase":
|
||||
return '"und-x-icu"';
|
||||
case "like":
|
||||
return "ilike";
|
||||
default:
|
||||
return $token;
|
||||
}
|
||||
|
|
|
@ -20,6 +20,9 @@ class Query {
|
|||
protected $qWhere = []; // WHERE clause components
|
||||
protected $tWhere = []; // WHERE clause type bindings
|
||||
protected $vWhere = []; // WHERE clause binding values
|
||||
protected $qWhereNot = []; // WHERE NOT clause components
|
||||
protected $tWhereNot = []; // WHERE NOT clause type bindings
|
||||
protected $vWhereNot = []; // WHERE NOT clause binding values
|
||||
protected $group = []; // GROUP BY clause components
|
||||
protected $order = []; // ORDER BY clause components
|
||||
protected $limit = 0;
|
||||
|
@ -69,6 +72,15 @@ class Query {
|
|||
return true;
|
||||
}
|
||||
|
||||
public function setWhereNot(string $where, $types = null, $values = null): bool {
|
||||
$this->qWhereNot[] = $where;
|
||||
if (!is_null($types)) {
|
||||
$this->tWhereNot[] = $types;
|
||||
$this->vWhereNot[] = $values;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function setGroup(string ...$column): bool {
|
||||
foreach ($column as $col) {
|
||||
$this->group[] = $col;
|
||||
|
@ -94,13 +106,16 @@ class Query {
|
|||
public function pushCTE(string $tableSpec, string $join = ''): bool {
|
||||
// this function takes the query body and converts it to a common table expression, putting it at the bottom of the existing CTE stack
|
||||
// all WHERE, ORDER BY, and LIMIT parts belong to the new CTE and are removed from the main query
|
||||
$this->setCTE($tableSpec, $this->buildQueryBody(), [$this->tBody, $this->tWhere], [$this->vBody, $this->vWhere]);
|
||||
$this->setCTE($tableSpec, $this->buildQueryBody(), [$this->tBody, $this->tWhere, $this->tWhereNot], [$this->vBody, $this->vWhere, $this->vWhereNot]);
|
||||
$this->jCTE = [];
|
||||
$this->tBody = [];
|
||||
$this->vBody = [];
|
||||
$this->qWhere = [];
|
||||
$this->tWhere = [];
|
||||
$this->vWhere = [];
|
||||
$this->qWhereNot = [];
|
||||
$this->tWhereNot = [];
|
||||
$this->vWhereNot = [];
|
||||
$this->qJoin = [];
|
||||
$this->tJoin = [];
|
||||
$this->vJoin = [];
|
||||
|
@ -129,11 +144,11 @@ class Query {
|
|||
}
|
||||
|
||||
public function getTypes(): array {
|
||||
return [$this->tCTE, $this->tBody, $this->tJoin, $this->tWhere];
|
||||
return [$this->tCTE, $this->tBody, $this->tJoin, $this->tWhere, $this->tWhereNot];
|
||||
}
|
||||
|
||||
public function getValues(): array {
|
||||
return [$this->vCTE, $this->vBody, $this->vJoin, $this->vWhere];
|
||||
return [$this->vCTE, $this->vBody, $this->vJoin, $this->vWhere, $this->vWhereNot];
|
||||
}
|
||||
|
||||
public function getJoinTypes(): array {
|
||||
|
@ -173,8 +188,12 @@ class Query {
|
|||
$out .= " ".implode(" ", $this->qJoin);
|
||||
}
|
||||
// add any WHERE terms
|
||||
if (sizeof($this->qWhere)) {
|
||||
$out .= " WHERE ".implode(" AND ", $this->qWhere);
|
||||
if (sizeof($this->qWhere) || sizeof($this->qWhereNot)) {
|
||||
$where = implode(" AND ", $this->qWhere);
|
||||
$whereNot = implode(" OR ", $this->qWhereNot);
|
||||
$whereNot = strlen($whereNot) ? "NOT ($whereNot)" : "";
|
||||
$where = implode(" AND ", array_filter([$where, $whereNot]));
|
||||
$out .= " WHERE $where";
|
||||
}
|
||||
// add any GROUP BY terms
|
||||
if (sizeof($this->group)) {
|
||||
|
|
|
@ -10,7 +10,7 @@ use JKingWeb\Arsse\Arsse;
|
|||
use JKingWeb\Arsse\Database;
|
||||
use JKingWeb\Arsse\User;
|
||||
use JKingWeb\Arsse\Service;
|
||||
use JKingWeb\Arsse\Misc\Context;
|
||||
use JKingWeb\Arsse\Context\Context;
|
||||
use JKingWeb\Arsse\Misc\ValueInfo;
|
||||
use JKingWeb\Arsse\AbstractException;
|
||||
use JKingWeb\Arsse\Db\ExceptionInput;
|
||||
|
|
|
@ -12,7 +12,7 @@ use JKingWeb\Arsse\Database;
|
|||
use JKingWeb\Arsse\User;
|
||||
use JKingWeb\Arsse\Service;
|
||||
use JKingWeb\Arsse\Misc\Date;
|
||||
use JKingWeb\Arsse\Misc\Context;
|
||||
use JKingWeb\Arsse\Context\Context;
|
||||
use JKingWeb\Arsse\Misc\ValueInfo;
|
||||
use JKingWeb\Arsse\AbstractException;
|
||||
use JKingWeb\Arsse\ExceptionType;
|
||||
|
@ -49,7 +49,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
|
|||
'sid' => ValueInfo::T_STRING, // session ID
|
||||
'seq' => ValueInfo::T_INT, // request number from client
|
||||
'user' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // user name for `login`
|
||||
'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // password for `login` and `subscribeToFeed`
|
||||
'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // password for `login` or remote password for `subscribeToFeed`
|
||||
'include_empty' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include empty items in `getFeedTree` and `getCategories`
|
||||
'unread_only' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to exclude items without unread articles in `getCategories` and `getFeeds`
|
||||
'enable_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to NOT show subcategories in `getCategories
|
||||
|
@ -76,7 +76,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
|
|||
'since_id' => ValueInfo::T_INT, // cut-off article ID for `getHeadlines` and `getCompactHeadlines; returns only higher article IDs when specified
|
||||
'order_by' => ValueInfo::T_STRING, // sort order for `getHeadlines`
|
||||
'include_header' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to attach a header to the results of `getHeadlines`
|
||||
'search' => ValueInfo::T_STRING, // search string for `getHeadlines` (not yet implemented)
|
||||
'search' => ValueInfo::T_STRING, // search string for `getHeadlines`
|
||||
'field' => ValueInfo::T_INT, // which state to change in `updateArticle`
|
||||
'mode' => ValueInfo::T_INT, // whether to set, clear, or toggle the selected state in `updateArticle`
|
||||
'data' => ValueInfo::T_STRING, // note text in `updateArticle` if setting a note
|
||||
|
@ -1478,7 +1478,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
|
|||
default:
|
||||
throw new \JKingWeb\Arsse\Exception("constantUnknown", $viewMode); // @codeCoverageIgnore
|
||||
}
|
||||
// TODO: implement searching
|
||||
// handle the search string, if any
|
||||
if (isset($data['search'])) {
|
||||
$c = Search::parse($data['search'], $c);
|
||||
if (!$c) {
|
||||
// the search string inherently returns an empty result, either directly or interacting with other input
|
||||
return new ResultEmpty;
|
||||
}
|
||||
}
|
||||
// handle sorting
|
||||
switch ($data['order_by']) {
|
||||
case "date_reverse":
|
||||
|
|
361
lib/REST/TinyTinyRSS/Search.php
Normal file
361
lib/REST/TinyTinyRSS/Search.php
Normal file
|
@ -0,0 +1,361 @@
|
|||
<?php
|
||||
/** @license MIT
|
||||
* Copyright 2017 J. King, Dustin Wilson et al.
|
||||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace JKingWeb\Arsse\REST\TinyTinyRSS;
|
||||
|
||||
use JKingWeb\Arsse\Context\Context;
|
||||
use JKingWeb\Arsse\Misc\Date;
|
||||
|
||||
class Search {
|
||||
const STATE_BEFORE_TOKEN = 0;
|
||||
const STATE_BEFORE_TOKEN_QUOTED = 1;
|
||||
const STATE_IN_DATE = 2;
|
||||
const STATE_IN_DATE_QUOTED = 3;
|
||||
const STATE_IN_TOKEN_OR_TAG = 4;
|
||||
const STATE_IN_TOKEN_OR_TAG_QUOTED = 5;
|
||||
const STATE_IN_TOKEN = 6;
|
||||
const STATE_IN_TOKEN_QUOTED = 7;
|
||||
|
||||
const FIELDS_BOOLEAN = [
|
||||
"unread" => "unread",
|
||||
"star" => "starred",
|
||||
"note" => "annotated",
|
||||
"pub" => "published", // TODO: not implemented
|
||||
];
|
||||
const FIELDS_TEXT = [
|
||||
"title" => "titleTerms",
|
||||
"author" => "authorTerms",
|
||||
"note" => "annotationTerms",
|
||||
"" => "searchTerms",
|
||||
];
|
||||
|
||||
public static function parse(string $search, Context $context = null) {
|
||||
// normalize the input
|
||||
$search = strtolower(trim(preg_replace("<\s+>", " ", $search)));
|
||||
// set initial state
|
||||
$tokens = [];
|
||||
$pos = -1;
|
||||
$stop = strlen($search);
|
||||
$state = self::STATE_BEFORE_TOKEN;
|
||||
$buffer = "";
|
||||
$tag = "";
|
||||
$flag_negative = false;
|
||||
$context = $context ?? new Context;
|
||||
// process
|
||||
try {
|
||||
while (++$pos <= $stop) {
|
||||
$char = @$search[$pos];
|
||||
switch ($state) {
|
||||
case self::STATE_BEFORE_TOKEN:
|
||||
switch ($char) {
|
||||
case "":
|
||||
continue 3;
|
||||
case " ":
|
||||
continue 3;
|
||||
case '"':
|
||||
if ($flag_negative) {
|
||||
$buffer .= $char;
|
||||
$state = self::STATE_IN_TOKEN_OR_TAG;
|
||||
} else {
|
||||
$state = self::STATE_BEFORE_TOKEN_QUOTED;
|
||||
}
|
||||
continue 3;
|
||||
case "-":
|
||||
if (!$flag_negative) {
|
||||
$flag_negative = true;
|
||||
} else {
|
||||
$buffer .= $char;
|
||||
$state = self::STATE_IN_TOKEN_OR_TAG;
|
||||
}
|
||||
continue 3;
|
||||
case "@":
|
||||
$state = self::STATE_IN_DATE;
|
||||
continue 3;
|
||||
case ":":
|
||||
$state = self::STATE_IN_TOKEN;
|
||||
continue 3;
|
||||
default:
|
||||
$buffer .= $char;
|
||||
$state = self::STATE_IN_TOKEN_OR_TAG;
|
||||
continue 3;
|
||||
}
|
||||
case self::STATE_BEFORE_TOKEN_QUOTED:
|
||||
switch ($char) {
|
||||
case "":
|
||||
continue 3;
|
||||
case '"':
|
||||
if (($pos + 1 == $stop) || $search[$pos + 1] === " ") {
|
||||
$context = self::processToken($context, $buffer, $tag, $flag_negative, false);
|
||||
$state = self::STATE_BEFORE_TOKEN;
|
||||
$flag_negative = false;
|
||||
$buffer = $tag = "";
|
||||
} elseif ($search[$pos + 1] === '"') {
|
||||
$buffer .= '"';
|
||||
$pos++;
|
||||
$state = self::STATE_IN_TOKEN_OR_TAG_QUOTED;
|
||||
} else {
|
||||
$state = self::STATE_IN_TOKEN_OR_TAG;
|
||||
}
|
||||
continue 3;
|
||||
case "\\":
|
||||
if ($pos + 1 == $stop) {
|
||||
$buffer .= $char;
|
||||
} elseif ($search[$pos + 1] === '"') {
|
||||
$buffer .= '"';
|
||||
$pos++;
|
||||
} else {
|
||||
$buffer .= $char;
|
||||
}
|
||||
$state = self::STATE_IN_TOKEN_OR_TAG_QUOTED;
|
||||
continue 3;
|
||||
case "-":
|
||||
if (!$flag_negative) {
|
||||
$flag_negative = true;
|
||||
} else {
|
||||
$buffer .= $char;
|
||||
$state = self::STATE_IN_TOKEN_OR_TAG_QUOTED;
|
||||
}
|
||||
continue 3;
|
||||
case "@":
|
||||
$state = self::STATE_IN_DATE_QUOTED;
|
||||
continue 3;
|
||||
case ":":
|
||||
$state = self::STATE_IN_TOKEN_QUOTED;
|
||||
continue 3;
|
||||
default:
|
||||
$buffer .= $char;
|
||||
$state = self::STATE_IN_TOKEN_OR_TAG_QUOTED;
|
||||
continue 3;
|
||||
}
|
||||
case self::STATE_IN_DATE:
|
||||
while ($pos < $stop && $search[$pos] !== " ") {
|
||||
$buffer .= $search[$pos++];
|
||||
}
|
||||
$context = self::processToken($context, $buffer, $tag, $flag_negative, true);
|
||||
$state = self::STATE_BEFORE_TOKEN;
|
||||
$flag_negative = false;
|
||||
$buffer = $tag = "";
|
||||
continue 2;
|
||||
case self::STATE_IN_DATE_QUOTED:
|
||||
switch ($char) {
|
||||
case "":
|
||||
case '"':
|
||||
if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") {
|
||||
$context = self::processToken($context, $buffer, $tag, $flag_negative, true);
|
||||
$state = self::STATE_BEFORE_TOKEN;
|
||||
$flag_negative = false;
|
||||
$buffer = $tag = "";
|
||||
} elseif ($search[$pos + 1] === '"') {
|
||||
$buffer .= '"';
|
||||
$pos++;
|
||||
} else {
|
||||
$state = self::STATE_IN_DATE;
|
||||
}
|
||||
continue 3;
|
||||
case "\\":
|
||||
if ($pos + 1 == $stop) {
|
||||
$buffer .= $char;
|
||||
} elseif ($search[$pos + 1] === '"') {
|
||||
$buffer .= '"';
|
||||
$pos++;
|
||||
} else {
|
||||
$buffer .= $char;
|
||||
}
|
||||
continue 3;
|
||||
default:
|
||||
$buffer .= $char;
|
||||
continue 3;
|
||||
}
|
||||
case self::STATE_IN_TOKEN:
|
||||
while ($pos < $stop && $search[$pos] !== " ") {
|
||||
$buffer .= $search[$pos++];
|
||||
}
|
||||
if (!strlen($tag)) {
|
||||
$buffer = ":".$buffer;
|
||||
}
|
||||
$context = self::processToken($context, $buffer, $tag, $flag_negative, false);
|
||||
$state = self::STATE_BEFORE_TOKEN;
|
||||
$flag_negative = false;
|
||||
$buffer = $tag = "";
|
||||
continue 2;
|
||||
case self::STATE_IN_TOKEN_QUOTED:
|
||||
switch ($char) {
|
||||
case "":
|
||||
case '"':
|
||||
if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") {
|
||||
if (!strlen($tag)) {
|
||||
$buffer = ":".$buffer;
|
||||
}
|
||||
$context = self::processToken($context, $buffer, $tag, $flag_negative, false);
|
||||
$state = self::STATE_BEFORE_TOKEN;
|
||||
$flag_negative = false;
|
||||
$buffer = $tag = "";
|
||||
} elseif ($search[$pos + 1] === '"') {
|
||||
$buffer .= '"';
|
||||
$pos++;
|
||||
} else {
|
||||
$state = self::STATE_IN_TOKEN;
|
||||
}
|
||||
continue 3;
|
||||
case "\\":
|
||||
if ($pos + 1 == $stop) {
|
||||
$buffer .= $char;
|
||||
} elseif ($search[$pos + 1] === '"') {
|
||||
$buffer .= '"';
|
||||
$pos++;
|
||||
} else {
|
||||
$buffer .= $char;
|
||||
}
|
||||
continue 3;
|
||||
default:
|
||||
$buffer .= $char;
|
||||
continue 3;
|
||||
}
|
||||
case self::STATE_IN_TOKEN_OR_TAG:
|
||||
switch ($char) {
|
||||
case "":
|
||||
case " ":
|
||||
$context = self::processToken($context, $buffer, $tag, $flag_negative, false);
|
||||
$state = self::STATE_BEFORE_TOKEN;
|
||||
$flag_negative = false;
|
||||
$buffer = $tag = "";
|
||||
continue 3;
|
||||
case ":";
|
||||
$tag = $buffer;
|
||||
$buffer = "";
|
||||
$state = self::STATE_IN_TOKEN;
|
||||
continue 3;
|
||||
default:
|
||||
$buffer .= $char;
|
||||
continue 3;
|
||||
}
|
||||
case self::STATE_IN_TOKEN_OR_TAG_QUOTED:
|
||||
switch ($char) {
|
||||
case "":
|
||||
case '"':
|
||||
if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") {
|
||||
$context = self::processToken($context, $buffer, $tag, $flag_negative, false);
|
||||
$state = self::STATE_BEFORE_TOKEN;
|
||||
$flag_negative = false;
|
||||
$buffer = $tag = "";
|
||||
} elseif ($search[$pos + 1] === '"') {
|
||||
$buffer .= '"';
|
||||
$pos++;
|
||||
} else {
|
||||
$state = self::STATE_IN_TOKEN_OR_TAG;
|
||||
}
|
||||
continue 3;
|
||||
case "\\":
|
||||
if ($pos + 1 == $stop) {
|
||||
$buffer .= $char;
|
||||
} elseif ($search[$pos + 1] === '"') {
|
||||
$buffer .= '"';
|
||||
$pos++;
|
||||
} else {
|
||||
$buffer .= $char;
|
||||
}
|
||||
continue 3;
|
||||
case ":":
|
||||
$tag = $buffer;
|
||||
$buffer = "";
|
||||
$state = self::STATE_IN_TOKEN_QUOTED;
|
||||
continue 3;
|
||||
default:
|
||||
$buffer .= $char;
|
||||
continue 3;
|
||||
}
|
||||
default:
|
||||
throw new \Exception; // @codeCoverageIgnore
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
return null;
|
||||
}
|
||||
return $context;
|
||||
}
|
||||
|
||||
protected static function processToken(Context $c, string $value, string $tag, bool $neg, bool $date): Context {
|
||||
if (!strlen($value) && !strlen($tag)) {
|
||||
return $c;
|
||||
} elseif (!strlen($value)) {
|
||||
// if a tag has an empty value, the tag is treated as a search term instead
|
||||
$value = "$tag:";
|
||||
$tag = "";
|
||||
}
|
||||
if ($date) {
|
||||
return self::setDate($value, $c, $neg);
|
||||
} elseif (isset(self::FIELDS_BOOLEAN[$tag])) {
|
||||
return self::setBoolean($tag, $value, $c, $neg);
|
||||
} else {
|
||||
return self::addTerm($tag, $value, $c, $neg);
|
||||
}
|
||||
}
|
||||
|
||||
protected static function addTerm(string $tag, string $value, Context $c, bool $neg): Context {
|
||||
$c = $neg ? $c->not : $c;
|
||||
$type = self::FIELDS_TEXT[$tag] ?? "";
|
||||
if (!$type) {
|
||||
$value = "$tag:$value";
|
||||
$type = self::FIELDS_TEXT[""];
|
||||
}
|
||||
return $c->$type(array_merge($c->$type ?? [], [$value]));
|
||||
}
|
||||
|
||||
protected static function setDate(string $value, Context $c, bool $neg): Context {
|
||||
$spec = Date::normalize($value);
|
||||
// TTRSS treats invalid dates as the start of the Unix epoch; we ignore them instead
|
||||
if (!$spec) {
|
||||
return $c;
|
||||
}
|
||||
$day = $spec->format("Y-m-d");
|
||||
$start = $day."T00:00:00+00:00";
|
||||
$end = $day."T23:59:59+00:00";
|
||||
// if a date is already set, the same date is a no-op; anything else is a contradiction
|
||||
$cc = $neg ? $c->not : $c;
|
||||
if ($cc->modifiedSince() || $cc->notModifiedSince()) {
|
||||
if (!$cc->modifiedSince() || !$cc->notModifiedSince() || $cc->modifiedSince->format("c") !== $start || $cc->notModifiedSince->format("c") !== $end) {
|
||||
// FIXME: multiple negative dates should be allowed, but the design of the Context class does not support this
|
||||
throw new Exception;
|
||||
} else {
|
||||
return $c;
|
||||
}
|
||||
}
|
||||
$cc->modifiedSince($start);
|
||||
$cc->notModifiedSince($end);
|
||||
return $c;
|
||||
}
|
||||
|
||||
protected static function setBoolean(string $tag, string $value, Context $c, bool $neg): Context {
|
||||
$set = ["true" => true, "false" => false][$value] ?? null;
|
||||
if (is_null($set)) {
|
||||
return self::addTerm($tag, $value, $c, $neg);
|
||||
} else {
|
||||
// apply negation
|
||||
$set = $neg ? !$set : $set;
|
||||
if ($tag === "pub") {
|
||||
// TODO: this needs to be implemented correctly if the Published feed is implemented
|
||||
// currently specifying true will always yield an empty result (nothing is ever published), and specifying false is a no-op (matches everything)
|
||||
if ($set) {
|
||||
throw new Exception;
|
||||
} else {
|
||||
return $c;
|
||||
}
|
||||
} else {
|
||||
$field = (self::FIELDS_BOOLEAN[$tag] ?? "");
|
||||
if (!$c->$field()) {
|
||||
// field has not yet been set; set it
|
||||
return $c->$field($set);
|
||||
} elseif ($c->$field == $set) {
|
||||
// field is already set to same value; do nothing
|
||||
return $c;
|
||||
} else {
|
||||
// contradiction: query would return no results
|
||||
throw new Exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -66,7 +66,7 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest {
|
|||
|
||||
public function setUp() {
|
||||
// get the name of the test's test series
|
||||
$this->series = $this->findTraitofTest($this->getName());
|
||||
$this->series = $this->findTraitofTest($this->getName(false));
|
||||
static::clearData();
|
||||
static::setConf();
|
||||
if (strlen(static::$failureReason)) {
|
||||
|
@ -88,7 +88,7 @@ abstract class Base extends \JKingWeb\Arsse\Test\AbstractTest {
|
|||
|
||||
public function tearDown() {
|
||||
// call the series-specific teardown method
|
||||
$this->series = $this->findTraitofTest($this->getName());
|
||||
$this->series = $this->findTraitofTest($this->getName(false));
|
||||
$tearDown = "tearDown".$this->series;
|
||||
$this->$tearDown();
|
||||
// clean up
|
||||
|
|
|
@ -8,7 +8,7 @@ namespace JKingWeb\Arsse\TestCase\Database;
|
|||
|
||||
use JKingWeb\Arsse\Database;
|
||||
use JKingWeb\Arsse\Arsse;
|
||||
use JKingWeb\Arsse\Misc\Context;
|
||||
use JKingWeb\Arsse\Context\Context;
|
||||
use JKingWeb\Arsse\Misc\Date;
|
||||
use Phake;
|
||||
|
||||
|
@ -111,13 +111,13 @@ trait SeriesArticle {
|
|||
'modified' => "datetime",
|
||||
],
|
||||
'rows' => [
|
||||
[1,1,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
|
||||
[2,1,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
|
||||
[3,2,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
|
||||
[4,2,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
|
||||
[5,3,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
|
||||
[6,3,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
|
||||
[7,4,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
|
||||
[1,1,null,"Title one", null,null,null,"First article", null,"","","","2000-01-01T00:00:00Z"],
|
||||
[2,1,null,"Title two", null,null,null,"Second article",null,"","","","2010-01-01T00:00:00Z"],
|
||||
[3,2,null,"Title three",null,null,null,"Third article", null,"","","","2000-01-01T00:00:00Z"],
|
||||
[4,2,null,null,"John Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z"],
|
||||
[5,3,null,null,"John Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z"],
|
||||
[6,3,null,null,"Jane Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z"],
|
||||
[7,4,null,null,"Jane Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z"],
|
||||
[8,4,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
|
||||
[9,5,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
|
||||
[10,5,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
|
||||
|
@ -377,6 +377,87 @@ trait SeriesArticle {
|
|||
unset($this->data, $this->matches, $this->fields, $this->checkTables, $this->user);
|
||||
}
|
||||
|
||||
/** @dataProvider provideContextMatches */
|
||||
public function testListArticlesCheckingContext(Context $c, array $exp) {
|
||||
$ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c)->getAll(), "id");
|
||||
sort($ids);
|
||||
sort($exp);
|
||||
$this->assertEquals($exp, $ids);
|
||||
}
|
||||
|
||||
public function provideContextMatches() {
|
||||
return [
|
||||
"Blank context" => [new Context, [1,2,3,4,5,6,7,8,19,20]],
|
||||
"Folder tree" => [(new Context)->folder(1), [5,6,7,8]],
|
||||
"Leaf folder" => [(new Context)->folder(6), [7,8]],
|
||||
"Root folder only" => [(new Context)->folderShallow(0), [1,2,3,4]],
|
||||
"Shallow folder" => [(new Context)->folderShallow(1), [5,6]],
|
||||
"Subscription" => [(new Context)->subscription(5), [19,20]],
|
||||
"Unread" => [(new Context)->subscription(5)->unread(true), [20]],
|
||||
"Read" => [(new Context)->subscription(5)->unread(false), [19]],
|
||||
"Starred" => [(new Context)->starred(true), [1,20]],
|
||||
"Unstarred" => [(new Context)->starred(false), [2,3,4,5,6,7,8,19]],
|
||||
"Starred and Read" => [(new Context)->starred(true)->unread(false), [1]],
|
||||
"Starred and Read in subscription" => [(new Context)->starred(true)->unread(false)->subscription(5), []],
|
||||
"Annotated" => [(new Context)->annotated(true), [2]],
|
||||
"Not annotated" => [(new Context)->annotated(false), [1,3,4,5,6,7,8,19,20]],
|
||||
"Labelled" => [(new Context)->labelled(true), [1,5,8,19,20]],
|
||||
"Not labelled" => [(new Context)->labelled(false), [2,3,4,6,7]],
|
||||
"Not after edition 999" => [(new Context)->subscription(5)->latestEdition(999), [19]],
|
||||
"Not after edition 19" => [(new Context)->subscription(5)->latestEdition(19), [19]],
|
||||
"Not before edition 999" => [(new Context)->subscription(5)->oldestEdition(999), [20]],
|
||||
"Not before edition 1001" => [(new Context)->subscription(5)->oldestEdition(1001), [20]],
|
||||
"Not after article 3" => [(new Context)->latestArticle(3), [1,2,3]],
|
||||
"Not before article 19" => [(new Context)->oldestArticle(19), [19,20]],
|
||||
"Modified by author since 2005" => [(new Context)->modifiedSince("2005-01-01T00:00:00Z"), [2,4,6,8,20]],
|
||||
"Modified by author since 2010" => [(new Context)->modifiedSince("2010-01-01T00:00:00Z"), [2,4,6,8,20]],
|
||||
"Not modified by author since 2005" => [(new Context)->notModifiedSince("2005-01-01T00:00:00Z"), [1,3,5,7,19]],
|
||||
"Not modified by author since 2000" => [(new Context)->notModifiedSince("2000-01-01T00:00:00Z"), [1,3,5,7,19]],
|
||||
"Marked or labelled since 2014" => [(new Context)->markedSince("2014-01-01T00:00:00Z"), [8,19]],
|
||||
"Marked or labelled since 2010" => [(new Context)->markedSince("2010-01-01T00:00:00Z"), [2,4,6,8,19,20]],
|
||||
"Not marked or labelled since 2014" => [(new Context)->notMarkedSince("2014-01-01T00:00:00Z"), [1,2,3,4,5,6,7,20]],
|
||||
"Not marked or labelled since 2005" => [(new Context)->notMarkedSince("2005-01-01T00:00:00Z"), [1,3,5,7]],
|
||||
"Marked or labelled between 2000 and 2015" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]],
|
||||
"Marked or labelled in 2010" => [(new Context)->markedSince("2010-01-01T00:00:00Z")->notMarkedSince("2010-12-31T23:59:59Z"), [2,4,6,20]],
|
||||
"Paged results" => [(new Context)->limit(2)->oldestEdition(4), [4,5]],
|
||||
"Reversed paged results" => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]],
|
||||
"With label ID 1" => [(new Context)->label(1), [1,19]],
|
||||
"With label ID 2" => [(new Context)->label(2), [1,5,20]],
|
||||
"With label 'Interesting'" => [(new Context)->labelName("Interesting"), [1,19]],
|
||||
"With label 'Fascinating'" => [(new Context)->labelName("Fascinating"), [1,5,20]],
|
||||
"Article ID 20" => [(new Context)->article(20), [20]],
|
||||
"Edition ID 1001" => [(new Context)->edition(1001), [20]],
|
||||
"Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]],
|
||||
"Multiple starred articles" => [(new Context)->articles([1,2,3])->starred(true), [1]],
|
||||
"Multiple unstarred articles" => [(new Context)->articles([1,2,3])->starred(false), [2,3]],
|
||||
"Multiple articles" => [(new Context)->articles([1,20,50]), [1,20]],
|
||||
"Multiple editions" => [(new Context)->editions([1,1001,50]), [1,20]],
|
||||
"150 articles" => [(new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)), [1,2,3,4,5,6,7,8,19,20]],
|
||||
"Search title or content 1" => [(new Context)->searchTerms(["Article"]), [1,2,3]],
|
||||
"Search title or content 2" => [(new Context)->searchTerms(["one", "first"]), [1]],
|
||||
"Search title or content 3" => [(new Context)->searchTerms(["one first"]), []],
|
||||
"Search title 1" => [(new Context)->titleTerms(["two"]), [2]],
|
||||
"Search title 2" => [(new Context)->titleTerms(["title two"]), [2]],
|
||||
"Search title 3" => [(new Context)->titleTerms(["two", "title"]), [2]],
|
||||
"Search title 4" => [(new Context)->titleTerms(["two title"]), []],
|
||||
"Search note 1" => [(new Context)->annotationTerms(["some"]), [2]],
|
||||
"Search note 2" => [(new Context)->annotationTerms(["some Note"]), [2]],
|
||||
"Search note 3" => [(new Context)->annotationTerms(["note", "some"]), [2]],
|
||||
"Search note 4" => [(new Context)->annotationTerms(["some", "sauce"]), []],
|
||||
"Search author 1" => [(new Context)->authorTerms(["doe"]), [4,5,6,7]],
|
||||
"Search author 2" => [(new Context)->authorTerms(["jane doe"]), [6,7]],
|
||||
"Search author 3" => [(new Context)->authorTerms(["doe", "jane"]), [6,7]],
|
||||
"Search author 4" => [(new Context)->authorTerms(["doe jane"]), []],
|
||||
"Folder tree 1 excluding subscription 4" => [(new Context)->not->subscription(4)->folder(1), [5,6]],
|
||||
"Folder tree 1 excluding articles 7 and 8" => [(new Context)->folder(1)->not->articles([7,8]), [5,6]],
|
||||
"Folder tree 1 excluding no articles" => [(new Context)->folder(1)->not->articles([]), [5,6,7,8]],
|
||||
"Marked or labelled between 2000 and 2015 excluding in 2010" => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59")->not->markedSince("2010-01-01T00:00:00Z")->not->notMarkedSince("2010-12-31T23:59:59Z"), [1,3,5,7,8]],
|
||||
"Search with exclusion" => [(new Context)->searchTerms(["Article"])->not->searchTerms(["one", "two"]), [3]],
|
||||
"Excluded folder tree" => [(new Context)->not->folder(1), [1,2,3,4,19,20]],
|
||||
"Excluding label ID 2" => [(new Context)->not->label(2), [2,3,4,6,7,8,19]],
|
||||
];
|
||||
}
|
||||
|
||||
public function testRetrieveArticleIdsForEditions() {
|
||||
$exp = [
|
||||
1 => 1,
|
||||
|
@ -414,88 +495,6 @@ trait SeriesArticle {
|
|||
$this->assertEquals($exp, Arsse::$db->editionArticle(...range(1, 1001)));
|
||||
}
|
||||
|
||||
public function testListArticlesCheckingContext() {
|
||||
$compareIds = function(array $exp, Context $c) {
|
||||
$ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c)->getAll(), "id");
|
||||
sort($ids);
|
||||
sort($exp);
|
||||
$this->assertEquals($exp, $ids);
|
||||
};
|
||||
// get all items for user
|
||||
$exp = [1,2,3,4,5,6,7,8,19,20];
|
||||
$compareIds($exp, new Context);
|
||||
$compareIds($exp, (new Context)->articles(range(1, Database::LIMIT_ARTICLES * 3)));
|
||||
// get items from a folder tree
|
||||
$compareIds([5,6,7,8], (new Context)->folder(1));
|
||||
// get items from a leaf folder
|
||||
$compareIds([7,8], (new Context)->folder(6));
|
||||
// get items from a non-leaf folder without descending
|
||||
$compareIds([1,2,3,4], (new Context)->folderShallow(0));
|
||||
$compareIds([5,6], (new Context)->folderShallow(1));
|
||||
// get items from a single subscription
|
||||
$exp = [19,20];
|
||||
$compareIds($exp, (new Context)->subscription(5));
|
||||
// get un/read items from a single subscription
|
||||
$compareIds([20], (new Context)->subscription(5)->unread(true));
|
||||
$compareIds([19], (new Context)->subscription(5)->unread(false));
|
||||
// get starred articles
|
||||
$compareIds([1,20], (new Context)->starred(true));
|
||||
$compareIds([2,3,4,5,6,7,8,19], (new Context)->starred(false));
|
||||
$compareIds([1], (new Context)->starred(true)->unread(false));
|
||||
$compareIds([], (new Context)->starred(true)->unread(false)->subscription(5));
|
||||
// get items relative to edition
|
||||
$compareIds([19], (new Context)->subscription(5)->latestEdition(999));
|
||||
$compareIds([19], (new Context)->subscription(5)->latestEdition(19));
|
||||
$compareIds([20], (new Context)->subscription(5)->oldestEdition(999));
|
||||
$compareIds([20], (new Context)->subscription(5)->oldestEdition(1001));
|
||||
// get items relative to article ID
|
||||
$compareIds([1,2,3], (new Context)->latestArticle(3));
|
||||
$compareIds([19,20], (new Context)->oldestArticle(19));
|
||||
// get items relative to (feed) modification date
|
||||
$exp = [2,4,6,8,20];
|
||||
$compareIds($exp, (new Context)->modifiedSince("2005-01-01T00:00:00Z"));
|
||||
$compareIds($exp, (new Context)->modifiedSince("2010-01-01T00:00:00Z"));
|
||||
$exp = [1,3,5,7,19];
|
||||
$compareIds($exp, (new Context)->notModifiedSince("2005-01-01T00:00:00Z"));
|
||||
$compareIds($exp, (new Context)->notModifiedSince("2000-01-01T00:00:00Z"));
|
||||
// get items relative to (user) modification date (both marks and labels apply)
|
||||
$compareIds([8,19], (new Context)->markedSince("2014-01-01T00:00:00Z"));
|
||||
$compareIds([2,4,6,8,19,20], (new Context)->markedSince("2010-01-01T00:00:00Z"));
|
||||
$compareIds([1,2,3,4,5,6,7,20], (new Context)->notMarkedSince("2014-01-01T00:00:00Z"));
|
||||
$compareIds([1,3,5,7], (new Context)->notMarkedSince("2005-01-01T00:00:00Z"));
|
||||
// paged results
|
||||
$compareIds([1], (new Context)->limit(1));
|
||||
$compareIds([2], (new Context)->limit(1)->oldestEdition(1+1));
|
||||
$compareIds([3], (new Context)->limit(1)->oldestEdition(2+1));
|
||||
$compareIds([4,5], (new Context)->limit(2)->oldestEdition(3+1));
|
||||
// reversed results
|
||||
$compareIds([20], (new Context)->reverse(true)->limit(1));
|
||||
$compareIds([19], (new Context)->reverse(true)->limit(1)->latestEdition(1001-1));
|
||||
$compareIds([8], (new Context)->reverse(true)->limit(1)->latestEdition(19-1));
|
||||
$compareIds([7,6], (new Context)->reverse(true)->limit(2)->latestEdition(8-1));
|
||||
// get articles by label ID
|
||||
$compareIds([1,19], (new Context)->label(1));
|
||||
$compareIds([1,5,20], (new Context)->label(2));
|
||||
// get articles by label name
|
||||
$compareIds([1,19], (new Context)->labelName("Interesting"));
|
||||
$compareIds([1,5,20], (new Context)->labelName("Fascinating"));
|
||||
// get articles with any or no label
|
||||
$compareIds([1,5,8,19,20], (new Context)->labelled(true));
|
||||
$compareIds([2,3,4,6,7], (new Context)->labelled(false));
|
||||
// get a specific article or edition
|
||||
$compareIds([20], (new Context)->article(20));
|
||||
$compareIds([20], (new Context)->edition(1001));
|
||||
// get multiple specific articles or editions
|
||||
$compareIds([1,20], (new Context)->articles([1,20,50]));
|
||||
$compareIds([1,20], (new Context)->editions([1,1001,50]));
|
||||
// get articles base on whether or not they have notes
|
||||
$compareIds([1,3,4,5,6,7,8,19,20], (new Context)->annotated(false));
|
||||
$compareIds([2], (new Context)->annotated(true));
|
||||
// get specific starred articles
|
||||
$compareIds([1], (new Context)->articles([1,2,3])->starred(true));
|
||||
$compareIds([2,3], (new Context)->articles([1,2,3])->starred(false));
|
||||
}
|
||||
|
||||
public function testListArticlesOfAMissingFolder() {
|
||||
$this->assertException("idMissing", "Db", "ExceptionInput");
|
||||
Arsse::$db->articleList($this->user, (new Context)->folder(1));
|
||||
|
@ -985,4 +984,24 @@ trait SeriesArticle {
|
|||
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
|
||||
Arsse::$db->articleCategoriesGet($this->user, 19);
|
||||
}
|
||||
|
||||
public function testSearchTooFewTerms() {
|
||||
$this->assertException("tooShort", "Db", "ExceptionInput");
|
||||
Arsse::$db->articleList($this->user, (new Context)->searchTerms([]));
|
||||
}
|
||||
|
||||
public function testSearchTooManyTerms() {
|
||||
$this->assertException("tooLong", "Db", "ExceptionInput");
|
||||
Arsse::$db->articleList($this->user, (new Context)->searchTerms(range(1, 105)));
|
||||
}
|
||||
|
||||
public function testSearchTooFewTermsInNote() {
|
||||
$this->assertException("tooShort", "Db", "ExceptionInput");
|
||||
Arsse::$db->articleList($this->user, (new Context)->annotationTerms([]));
|
||||
}
|
||||
|
||||
public function testSearchTooManyTermsInNote() {
|
||||
$this->assertException("tooLong", "Db", "ExceptionInput");
|
||||
Arsse::$db->articleList($this->user, (new Context)->annotationTerms(range(1, 105)));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ declare(strict_types=1);
|
|||
namespace JKingWeb\Arsse\TestCase\Database;
|
||||
|
||||
use JKingWeb\Arsse\Arsse;
|
||||
use JKingWeb\Arsse\Misc\Context;
|
||||
use JKingWeb\Arsse\Context\Context;
|
||||
use JKingWeb\Arsse\Misc\Date;
|
||||
use Phake;
|
||||
|
||||
|
|
|
@ -94,6 +94,7 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest {
|
|||
public function testTranslateAToken() {
|
||||
$this->assertRegExp("/^[a-z][a-z0-9]*$/i", $this->drv->sqlToken("greatest"));
|
||||
$this->assertRegExp("/^\"?[a-z][a-z0-9_\-]*\"?$/i", $this->drv->sqlToken("nocase"));
|
||||
$this->assertRegExp("/^[a-z][a-z0-9]*$/i", $this->drv->sqlToken("like"));
|
||||
$this->assertSame("distinct", $this->drv->sqlToken("distinct"));
|
||||
}
|
||||
|
||||
|
|
|
@ -6,14 +6,15 @@
|
|||
declare(strict_types=1);
|
||||
namespace JKingWeb\Arsse\TestCase\Misc;
|
||||
|
||||
use JKingWeb\Arsse\Misc\Context;
|
||||
use JKingWeb\Arsse\Context\Context;
|
||||
use JKingWeb\Arsse\Misc\ValueInfo;
|
||||
|
||||
/** @covers \JKingWeb\Arsse\Misc\Context */
|
||||
/** @covers \JKingWeb\Arsse\Context\Context<extended> */
|
||||
class TestContext extends \JKingWeb\Arsse\Test\AbstractTest {
|
||||
public function testVerifyInitialState() {
|
||||
$c = new Context;
|
||||
foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) {
|
||||
if ($m->isConstructor() || $m->isStatic()) {
|
||||
if ($m->isStatic() || strpos($m->name, "__") === 0) {
|
||||
continue;
|
||||
}
|
||||
$method = $m->name;
|
||||
|
@ -48,11 +49,16 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest {
|
|||
'labelName' => "Rush",
|
||||
'labelled' => true,
|
||||
'annotated' => true,
|
||||
'searchTerms' => ["foo", "bar"],
|
||||
'annotationTerms' => ["foo", "bar"],
|
||||
'titleTerms' => ["foo", "bar"],
|
||||
'authorTerms' => ["foo", "bar"],
|
||||
'not' => (new Context)->subscription(5),
|
||||
];
|
||||
$times = ['modifiedSince','notModifiedSince','markedSince','notMarkedSince'];
|
||||
$c = new Context;
|
||||
foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) {
|
||||
if ($m->isConstructor() || $m->isStatic()) {
|
||||
if ($m->isStatic() || strpos($m->name, "__") === 0) {
|
||||
continue;
|
||||
}
|
||||
$method = $m->name;
|
||||
|
@ -70,7 +76,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest {
|
|||
}
|
||||
}
|
||||
|
||||
public function testCleanArrayValues() {
|
||||
public function testCleanIdArrayValues() {
|
||||
$methods = ["articles", "editions"];
|
||||
$in = [1, "2", 3.5, 3.0, "ook", 0, -20, true, false, null, new \DateTime(), -1.0];
|
||||
$out = [1,2, 3];
|
||||
|
@ -79,4 +85,26 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest {
|
|||
$this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results");
|
||||
}
|
||||
}
|
||||
|
||||
public function testCleanStringArrayValues() {
|
||||
$methods = ["searchTerms", "annotationTerms", "titleTerms", "authorTerms"];
|
||||
$now = new \DateTime;
|
||||
$in = [1, 3.0, "ook", 0, true, false, null, $now, ""];
|
||||
$out = ["1", "3", "ook", "0", valueInfo::normalize($now, ValueInfo::T_STRING)];
|
||||
$c = new Context;
|
||||
foreach ($methods as $method) {
|
||||
$this->assertSame($out, $c->$method($in)->$method, "Context method $method did not return the expected results");
|
||||
}
|
||||
}
|
||||
|
||||
public function testCloneAContext() {
|
||||
$c1 = new Context;
|
||||
$c2 = clone $c1;
|
||||
$this->assertEquals($c1, $c2);
|
||||
$this->assertEquals($c1->not, $c2->not);
|
||||
$this->assertNotSame($c1, $c2);
|
||||
$this->assertNotSame($c1->not, $c2->not);
|
||||
$this->assertSame($c1, $c1->not->article(null));
|
||||
$this->assertSame($c2, $c2->not->article(null));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ use JKingWeb\Arsse\Database;
|
|||
use JKingWeb\Arsse\Service;
|
||||
use JKingWeb\Arsse\Test\Result;
|
||||
use JKingWeb\Arsse\Misc\Date;
|
||||
use JKingWeb\Arsse\Misc\Context;
|
||||
use JKingWeb\Arsse\Context\Context;
|
||||
use JKingWeb\Arsse\Db\ExceptionInput;
|
||||
use JKingWeb\Arsse\Db\Transaction;
|
||||
use JKingWeb\Arsse\REST\NextCloudNews\V1_2;
|
||||
|
|
|
@ -14,7 +14,7 @@ use JKingWeb\Arsse\Service;
|
|||
use JKingWeb\Arsse\REST\Request;
|
||||
use JKingWeb\Arsse\Test\Result;
|
||||
use JKingWeb\Arsse\Misc\Date;
|
||||
use JKingWeb\Arsse\Misc\Context;
|
||||
use JKingWeb\Arsse\Context\Context;
|
||||
use JKingWeb\Arsse\Db\ExceptionInput;
|
||||
use JKingWeb\Arsse\Db\Transaction;
|
||||
use JKingWeb\Arsse\REST\TinyTinyRSS\API;
|
||||
|
@ -1809,6 +1809,8 @@ LONG_STRING;
|
|||
['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "published"],
|
||||
['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "unread"],
|
||||
['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112],
|
||||
['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "unread", 'search' => "unread:false"],
|
||||
['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'search' => "pub:true"],
|
||||
];
|
||||
$in2 = [
|
||||
// simple context tests
|
||||
|
@ -1833,6 +1835,7 @@ LONG_STRING;
|
|||
['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'is_cat' => true, 'include_nested' => true],
|
||||
['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'order_by' => "feed_dates"],
|
||||
['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'order_by' => "date_reverse"],
|
||||
['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'search' => "interesting"],
|
||||
];
|
||||
$in3 = [
|
||||
// time-based context tests
|
||||
|
@ -1868,6 +1871,7 @@ LONG_STRING;
|
|||
Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(42), $this->anything())->thenReturn($this->generateHeadlines(14));
|
||||
Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folder(42), $this->anything())->thenReturn($this->generateHeadlines(15));
|
||||
Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->reverse(false), $this->anything())->thenReturn($this->generateHeadlines(16));
|
||||
Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->searchTerms(["interesting"]), $this->anything())->thenReturn($this->generateHeadlines(17));
|
||||
$out2 = [
|
||||
$this->respErr("INCORRECT_USAGE"),
|
||||
$this->outputHeadlines(11),
|
||||
|
@ -1890,6 +1894,7 @@ LONG_STRING;
|
|||
$this->outputHeadlines(15),
|
||||
$this->outputHeadlines(11), // defaulting sorting is not fully implemented
|
||||
$this->outputHeadlines(16),
|
||||
$this->outputHeadlines(17),
|
||||
];
|
||||
$out3 = [
|
||||
$this->outputHeadlines(1001),
|
||||
|
|
126
tests/cases/REST/TinyTinyRSS/TestSearch.php
Normal file
126
tests/cases/REST/TinyTinyRSS/TestSearch.php
Normal file
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
/** @license MIT
|
||||
* Copyright 2017 J. King, Dustin Wilson et al.
|
||||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace JKingWeb\Arsse\TestCase\REST\TinyTinyRSS;
|
||||
|
||||
use JKingWeb\Arsse\Context\Context;
|
||||
use JKingWeb\Arsse\REST\TinyTinyRSS\Search;
|
||||
|
||||
/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Search */
|
||||
class TestSearch extends \JKingWeb\Arsse\Test\AbstractTest {
|
||||
public function provideSearchStrings() {
|
||||
return [
|
||||
'Blank string' => ["", new Context],
|
||||
'Whitespace only' => [" \n \t", new Context],
|
||||
'Simple bare token' => ['OOK', (new Context)->searchTerms(["ook"])],
|
||||
'Simple negative bare token' => ['-OOK', (new Context)->not->searchTerms(["ook"])],
|
||||
'Simple quoted token' => ['"OOK eek"', (new Context)->searchTerms(["ook eek"])],
|
||||
'Simple negative quoted token' => ['"-OOK eek"', (new Context)->not->searchTerms(["ook eek"])],
|
||||
'Simple bare tokens' => ['OOK eek', (new Context)->searchTerms(["ook", "eek"])],
|
||||
'Simple mixed bare tokens' => ['-OOK eek', (new Context)->not->searchTerms(["ook"])->searchTerms(["eek"])],
|
||||
'Unclosed quoted token' => ['"OOK eek', (new Context)->searchTerms(["ook eek"])],
|
||||
'Unclosed quoted token 2' => ['"OOK eek" "', (new Context)->searchTerms(["ook eek"])],
|
||||
'Broken quoted token 1' => ['"-OOK"eek"', (new Context)->not->searchTerms(["ookeek\""])],
|
||||
'Broken quoted token 2' => ['""eek"', (new Context)->searchTerms(["eek\""])],
|
||||
'Broken quoted token 3' => ['"-"eek"', (new Context)->not->searchTerms(["eek\""])],
|
||||
'Empty quoted token' => ['""', new Context],
|
||||
'Simple quoted tokens' => ['"OOK eek" "eek ack"', (new Context)->searchTerms(["ook eek", "eek ack"])],
|
||||
'Bare blank tag' => [':ook', (new Context)->searchTerms([":ook"])],
|
||||
'Quoted blank tag' => ['":ook"', (new Context)->searchTerms([":ook"])],
|
||||
'Bare negative blank tag' => ['-:ook', (new Context)->not->searchTerms([":ook"])],
|
||||
'Quoted negative blank tag' => ['"-:ook"', (new Context)->not->searchTerms([":ook"])],
|
||||
'Bare valueless blank tag' => [':', (new Context)->searchTerms([":"])],
|
||||
'Quoted valueless blank tag' => ['":"', (new Context)->searchTerms([":"])],
|
||||
'Bare negative valueless blank tag' => ['-:', (new Context)->not->searchTerms([":"])],
|
||||
'Quoted negative valueless blank tag' => ['"-:"', (new Context)->not->searchTerms([":"])],
|
||||
'Double negative' => ['--eek', (new Context)->not->searchTerms(["-eek"])],
|
||||
'Double negative 2' => ['--@eek', (new Context)->not->searchTerms(["-@eek"])],
|
||||
'Double negative 3' => ['"--@eek"', (new Context)->not->searchTerms(["-@eek"])],
|
||||
'Double negative 4' => ['"--eek"', (new Context)->not->searchTerms(["-eek"])],
|
||||
'Negative before quote' => ['-"ook"', (new Context)->not->searchTerms(["\"ook\""])],
|
||||
'Bare unread tag true' => ['UNREAD:true', (new Context)->unread(true)],
|
||||
'Bare unread tag false' => ['UNREAD:false', (new Context)->unread(false)],
|
||||
'Bare negative unread tag true' => ['-unread:true', (new Context)->unread(false)],
|
||||
'Bare negative unread tag false' => ['-unread:false', (new Context)->unread(true)],
|
||||
'Quoted unread tag true' => ['"UNREAD:true"', (new Context)->unread(true)],
|
||||
'Quoted unread tag false' => ['"UNREAD:false"', (new Context)->unread(false)],
|
||||
'Quoted negative unread tag true' => ['"-unread:true"', (new Context)->unread(false)],
|
||||
'Quoted negative unread tag false' => ['"-unread:false"', (new Context)->unread(true)],
|
||||
'Bare star tag true' => ['STAR:true', (new Context)->starred(true)],
|
||||
'Bare star tag false' => ['STAR:false', (new Context)->starred(false)],
|
||||
'Bare negative star tag true' => ['-star:true', (new Context)->starred(false)],
|
||||
'Bare negative star tag false' => ['-star:false', (new Context)->starred(true)],
|
||||
'Quoted star tag true' => ['"STAR:true"', (new Context)->starred(true)],
|
||||
'Quoted star tag false' => ['"STAR:false"', (new Context)->starred(false)],
|
||||
'Quoted negative star tag true' => ['"-star:true"', (new Context)->starred(false)],
|
||||
'Quoted negative star tag false' => ['"-star:false"', (new Context)->starred(true)],
|
||||
'Bare note tag true' => ['NOTE:true', (new Context)->annotated(true)],
|
||||
'Bare note tag false' => ['NOTE:false', (new Context)->annotated(false)],
|
||||
'Bare negative note tag true' => ['-note:true', (new Context)->annotated(false)],
|
||||
'Bare negative note tag false' => ['-note:false', (new Context)->annotated(true)],
|
||||
'Quoted note tag true' => ['"NOTE:true"', (new Context)->annotated(true)],
|
||||
'Quoted note tag false' => ['"NOTE:false"', (new Context)->annotated(false)],
|
||||
'Quoted negative note tag true' => ['"-note:true"', (new Context)->annotated(false)],
|
||||
'Quoted negative note tag false' => ['"-note:false"', (new Context)->annotated(true)],
|
||||
'Bare pub tag true' => ['PUB:true', null],
|
||||
'Bare pub tag false' => ['PUB:false', new Context],
|
||||
'Bare negative pub tag true' => ['-pub:true', new Context],
|
||||
'Bare negative pub tag false' => ['-pub:false', null],
|
||||
'Quoted pub tag true' => ['"PUB:true"', null],
|
||||
'Quoted pub tag false' => ['"PUB:false"', new Context],
|
||||
'Quoted negative pub tag true' => ['"-pub:true"', new Context],
|
||||
'Quoted negative pub tag false' => ['"-pub:false"', null],
|
||||
'Non-boolean unread tag' => ['unread:maybe', (new Context)->searchTerms(["unread:maybe"])],
|
||||
'Non-boolean star tag' => ['star:maybe', (new Context)->searchTerms(["star:maybe"])],
|
||||
'Non-boolean pub tag' => ['pub:maybe', (new Context)->searchTerms(["pub:maybe"])],
|
||||
'Non-boolean note tag' => ['note:maybe', (new Context)->annotationTerms(["maybe"])],
|
||||
'Valueless unread tag' => ['unread:', (new Context)->searchTerms(["unread:"])],
|
||||
'Valueless star tag' => ['star:', (new Context)->searchTerms(["star:"])],
|
||||
'Valueless pub tag' => ['pub:', (new Context)->searchTerms(["pub:"])],
|
||||
'Valueless note tag' => ['note:', (new Context)->searchTerms(["note:"])],
|
||||
'Valueless title tag' => ['title:', (new Context)->searchTerms(["title:"])],
|
||||
'Valueless author tag' => ['author:', (new Context)->searchTerms(["author:"])],
|
||||
'Escaped quote 1' => ['"""I say, Jeeves!"""', (new Context)->searchTerms(["\"i say, jeeves!\""])],
|
||||
'Escaped quote 2' => ['"\\"I say, Jeeves!\\""', (new Context)->searchTerms(["\"i say, jeeves!\""])],
|
||||
'Escaped quote 3' => ['\\"I say, Jeeves!\\"', (new Context)->searchTerms(["\\\"i", "say,", "jeeves!\\\""])],
|
||||
'Escaped quote 4' => ['"\\"\\I say, Jeeves!\\""', (new Context)->searchTerms(["\"\\i say, jeeves!\""])],
|
||||
'Escaped quote 5' => ['"\\I say, Jeeves!"', (new Context)->searchTerms(["\\i say, jeeves!"])],
|
||||
'Escaped quote 6' => ['"\\"I say, Jeeves!\\', (new Context)->searchTerms(["\"i say, jeeves!\\"])],
|
||||
'Escaped quote 7' => ['"\\', (new Context)->searchTerms(["\\"])],
|
||||
'Quoted author tag 1' => ['"author:Neal Stephenson"', (new Context)->authorTerms(["neal stephenson"])],
|
||||
'Quoted author tag 2' => ['"author:Jo ""Cap\'n Tripps"" Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\" ashburn"])],
|
||||
'Quoted author tag 3' => ['"author:Jo \\"Cap\'n Tripps\\" Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\" ashburn"])],
|
||||
'Quoted author tag 4' => ['"author:Jo ""Cap\'n Tripps"Ashburn"', (new Context)->authorTerms(["jo \"cap'n trippsashburn\""])],
|
||||
'Quoted author tag 5' => ['"author:Jo ""Cap\'n Tripps\ Ashburn"', (new Context)->authorTerms(["jo \"cap'n tripps\\ ashburn"])],
|
||||
'Quoted author tag 6' => ['"author:Neal Stephenson\\', (new Context)->authorTerms(["neal stephenson\\"])],
|
||||
'Quoted title tag' => ['"title:Generic title"', (new Context)->titleTerms(["generic title"])],
|
||||
'Contradictory booleans' => ['unread:true -unread:true', null],
|
||||
'Doubled boolean' => ['unread:true unread:true', (new Context)->unread(true)],
|
||||
'Bare blank date' => ['@', new Context],
|
||||
'Quoted blank date' => ['"@"', new Context],
|
||||
'Bare ISO date' => ['@2019-03-01', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")],
|
||||
'Quoted ISO date' => ['"@March 1st, 2019"', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")],
|
||||
'Bare negative ISO date' => ['-@2019-03-01', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")],
|
||||
'Quoted negative English date' => ['"-@March 1st, 2019"', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")],
|
||||
'Invalid date' => ['@Bugaboo', new Context],
|
||||
'Escaped quoted date 1' => ['"@""Yesterday" and today', (new Context)->searchTerms(["and", "today"])],
|
||||
'Escaped quoted date 2' => ['"@\\"Yesterday" and today', (new Context)->searchTerms(["and", "today"])],
|
||||
'Escaped quoted date 3' => ['"@Yesterday\\', new Context],
|
||||
'Escaped quoted date 4' => ['"@Yesterday\\and today', new Context],
|
||||
'Escaped quoted date 5' => ['"@Yesterday"and today', (new Context)->searchTerms(["today"])],
|
||||
'Contradictory dates' => ['@Yesterday @Today', null],
|
||||
'Doubled date' => ['"@March 1st, 2019" @2019-03-01', (new Context)->modifiedSince("2019-03-01T00:00:00Z")->notModifiedSince("2019-03-01T23:59:59Z")],
|
||||
'Doubled negative date' => ['"-@March 1st, 2019" -@2019-03-01', (new Context)->not->modifiedSince("2019-03-01T00:00:00Z")->not->notModifiedSince("2019-03-01T23:59:59Z")],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider provideSearchStrings */
|
||||
public function testApplySearchToContext(string $search, $exp) {
|
||||
$act = Search::parse($search);
|
||||
//var_export($act);
|
||||
$this->assertEquals($exp, $act);
|
||||
}
|
||||
}
|
|
@ -99,6 +99,7 @@
|
|||
<file>cases/REST/NextCloudNews/PDO/TestV1_2.php</file>
|
||||
</testsuite>
|
||||
<testsuite name="TTRSS">
|
||||
<file>cases/REST/TinyTinyRSS/TestSearch.php</file>
|
||||
<file>cases/REST/TinyTinyRSS/TestAPI.php</file>
|
||||
<file>cases/REST/TinyTinyRSS/TestIcon.php</file>
|
||||
<file>cases/REST/TinyTinyRSS/PDO/TestAPI.php</file>
|
||||
|
|
Loading…
Reference in a new issue