1
1
Fork 0
mirror of https://code.mensbeam.com/MensBeam/Arsse.git synced 2024-12-22 13:12:41 +00:00

Merge branch 'reader'

This commit is contained in:
J. King 2022-04-30 17:34:29 -04:00
commit a0c31fac5d
38 changed files with 1431 additions and 1059 deletions

View file

@ -1,3 +1,12 @@
Version 0.1?.? (2022-??-??)
===========================
Bug fixes:
- Return all removed articles when multiple statuses are requested in Miniflux
- Allow multiple date ranges in search strings in Tiny Tiny RSS
- Honour user time zone when interpreting search strings in Tiny Tiny RSS
- Perform MySQL table maintenance more reliably
Version 0.10.2 (2022-04-04)
===========================

2
composer.lock generated
View file

@ -1424,5 +1424,5 @@
"platform-overrides": {
"php": "7.1.33"
},
"plugin-api-version": "2.2.0"
"plugin-api-version": "2.3.0"
}

View file

@ -39,7 +39,6 @@ Miniflux version 2.0.28 is emulated, though not all features are implemented
- Filtering rules may not function identically (see below for details)
- The `checked_at` field of feeds indicates when the feed was last updated rather than when it was last checked
- Creating a feed with the `scrape` property set to `true` might not return scraped content for the initial synchronization
- Querying articles for both read/unread and removed statuses will not return all removed articles
- Search strings will match partial words
- OPML import either succeeds or fails atomically: if one feed fails, no feeds are imported

View file

@ -37,7 +37,7 @@ The Arsse does not currently support the entire protocol. Notably missing featur
- 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"`
- 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)
- Specifying multiple non-negative dates usually returns no results as articles must match all specified dates simultaneously; The Arsse instead returns articles matching any of the specified dates
- 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

View file

@ -0,0 +1,27 @@
<?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;
abstract class AbstractContext {
protected $props = [];
protected $parent = null;
protected function act(string $prop, int $set, $value) {
if ($set) {
if (is_null($value)) {
unset($this->props[$prop]);
$this->$prop = (new \ReflectionClass($this))->getDefaultProperties()[$prop];
} else {
$this->props[$prop] = true;
$this->$prop = $value;
}
return $this->parent ?? $this;
} else {
return isset($this->props[$prop]);
}
}
}

View file

@ -0,0 +1,35 @@
<?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;
trait BooleanMembers {
public $unread = null;
public $starred = null;
public $hidden = null;
public $labelled = null;
public $annotated = null;
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 hidden(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);
}
}

View file

@ -6,16 +6,12 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\Context;
class Context extends ExclusionContext {
class Context extends RootContext {
use BooleanMembers;
use ExclusionMembers;
/** @var ExclusionContext */
public $not;
public $limit = 0;
public $offset = 0;
public $unread;
public $starred;
public $hidden;
public $labelled;
public $annotated;
public function __construct() {
$this->not = new ExclusionContext($this);
@ -30,32 +26,4 @@ class Context extends ExclusionContext {
public function __destruct() {
unset($this->not);
}
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 hidden(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);
}
}

View file

@ -6,53 +6,18 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\Context;
use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\Misc\Date;
class ExclusionContext extends AbstractContext {
use ExclusionMembers;
class ExclusionContext {
public $folder;
public $folders;
public $folderShallow;
public $foldersShallow;
public $tag;
public $tags;
public $tagName;
public $tagNames;
public $subscription;
public $subscriptions;
public $edition;
public $editions;
public $article;
public $articles;
public $label;
public $labels;
public $labelName;
public $labelNames;
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 __construct(Context $parent = null) {
$this->parent = $parent;
}
public function __clone() {
// if the context was cloned because its parent was cloned, change the parent to the 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") {
if (($t['object'] ?? null) instanceof Context && $t['function'] === "__clone") {
$this->parent = $t['object'];
}
}
@ -62,209 +27,4 @@ class ExclusionContext {
public function __destruct() {
unset($this->parent);
}
protected function act(string $prop, int $set, $value) {
if ($set) {
if (is_null($value)) {
unset($this->props[$prop]);
$this->$prop = (new \ReflectionClass($this))->getDefaultProperties()[$prop];
} else {
$this->props[$prop] = true;
$this->$prop = $value;
}
return $this->parent ?? $this;
} else {
return isset($this->props[$prop]);
}
}
protected function cleanIdArray(array $spec, bool $allowZero = false): array {
$spec = array_values($spec);
for ($a = 0; $a < sizeof($spec); $a++) {
if (ValueInfo::id($spec[$a], $allowZero)) {
$spec[$a] = (int) $spec[$a];
} else {
$spec[$a] = null;
}
}
return array_values(array_unique(array_filter($spec, function($v) {
return !is_null($v);
})));
}
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) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function folders(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec, true);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function folderShallow(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function foldersShallow(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec, true);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function tag(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function tags(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function tagName(string $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function tagNames(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanStringArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function subscription(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function subscriptions(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($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->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 labels(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function labelName(string $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function labelNames(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanStringArray($spec);
}
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);
}
public function oldestArticle(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function latestEdition(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function oldestEdition(int $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);
}
public function notModifiedSince($spec = null) {
$spec = Date::normalize($spec);
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function markedSince($spec = null) {
$spec = Date::normalize($spec);
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function notMarkedSince($spec = null) {
$spec = Date::normalize($spec);
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
}

View file

@ -0,0 +1,262 @@
<?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;
use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\Misc\Date;
trait ExclusionMembers {
public $folder = null;
public $folders = [];
public $folderShallow = null;
public $foldersShallow = [];
public $tag = null;
public $tags = [];
public $tagName = null;
public $tagNames = [];
public $subscription = null;
public $subscriptions = [];
public $edition = null;
public $editions = [];
public $article = null;
public $articles = [];
public $label = null;
public $labels = [];
public $labelName = null;
public $labelNames = [];
public $annotationTerms = [];
public $searchTerms = [];
public $titleTerms = [];
public $authorTerms = [];
public $articleRange = [null, null];
public $editionRange = [null, null];
public $modifiedRange = [null, null];
public $modifiedRanges = [];
public $markedRange = [null, null];
public $markedRanges = [];
protected function cleanIdArray(array $spec, bool $allowZero = false): array {
$spec = array_values($spec);
for ($a = 0; $a < sizeof($spec); $a++) {
if (ValueInfo::id($spec[$a], $allowZero)) {
$spec[$a] = (int) $spec[$a];
} else {
$spec[$a] = null;
}
}
return array_values(array_unique(array_filter($spec, function($v) {
return !is_null($v);
})));
}
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));
}
protected function cleanDateRangeArray(array $spec): array {
$spec = array_values($spec);
$stop = sizeof($spec);
for ($a = 0; $a < $stop; $a++) {
if (!is_array($spec[$a]) || sizeof($spec[$a]) !== 2) {
unset($spec[$a]);
} else {
$spec[$a] = ValueInfo::normalize($spec[$a], ValueInfo::T_DATE | ValueInfo::M_ARRAY | ValueInfo::M_DROP);
if ($spec[$a] === [null, null]) {
unset($spec[$a]);
}
}
}
return array_values(array_unique($spec, \SORT_REGULAR));
}
public function folder(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function folders(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec, true);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function folderShallow(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function foldersShallow(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec, true);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function tag(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function tags(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function tagName(string $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function tagNames(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanStringArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function subscription(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function subscriptions(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($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->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 labels(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanIdArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function labelName(string $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function labelNames(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanStringArray($spec);
}
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 articleRange(?int $start = null, ?int $end = null) {
if ($start === null && $end === null) {
$spec = null;
} else {
$spec = [$start, $end];
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function editionRange(?int $start = null, ?int $end = null) {
if ($start === null && $end === null) {
$spec = null;
} else {
$spec = [$start, $end];
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function modifiedRange($start = null, $end = null) {
if ($start === null && $end === null) {
$spec = null;
} else {
$spec = [Date::normalize($start), Date::normalize($end)];
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function modifiedRanges(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanDateRangeArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function markedRange($start = null, $end = null) {
if ($start === null && $end === null) {
$spec = null;
} else {
$spec = [Date::normalize($start), Date::normalize($end)];
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function markedRanges(array $spec = null) {
if (isset($spec)) {
$spec = $this->cleanDateRangeArray($spec);
}
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
}

View file

@ -0,0 +1,20 @@
<?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;
abstract class RootContext extends AbstractContext {
public $limit = 0;
public $offset = 0;
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);
}
}

View file

@ -0,0 +1,50 @@
<?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 UnionContext extends RootContext implements \ArrayAccess, \Countable, \IteratorAggregate {
protected $contexts = [];
#[\ReturnTypeWillChange]
public function offsetExists($offset) {
return isset($this->contexts[$offset]);
}
#[\ReturnTypeWillChange]
public function offsetGet($offset) {
return $this->contexts[$offset] ?? null;
}
#[\ReturnTypeWillChange]
public function offsetSet($offset, $value) {
assert($value instanceof RootContext, new \Exception("Union contexts may only contain other non-exclusion contexts"));
if (isset($offset)) {
$this->contexts[$offset] = $value;
} else {
$this->contexts[] = $value;
}
}
#[\ReturnTypeWillChange]
public function offsetUnset($offset) {
unset($this->contexts[$offset]);
}
public function count(): int {
return count($this->contexts);
}
public function getIterator(): \Traversable {
foreach ($this->contexts as $k => $c) {
yield $k => $c;
}
}
public function __construct(RootContext ...$context) {
$this->contexts = $context;
}
}

View file

@ -10,7 +10,10 @@ use JKingWeb\DrUUID\UUID;
use JKingWeb\Arsse\Db\Statement;
use JKingWeb\Arsse\Misc\Query;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Context\UnionContext;
use JKingWeb\Arsse\Context\RootContext;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\QueryFilter;
use JKingWeb\Arsse\Misc\ValueInfo as V;
use JKingWeb\Arsse\Misc\URL;
use JKingWeb\Arsse\Rule\Rule;
@ -41,7 +44,8 @@ use JKingWeb\Arsse\Rule\Exception as RuleException;
* concerns, will typically follow different conventions.
*
* Note that operations on users should be performed with the User class rather
* than the Database class directly. This is to allow for alternate user sources.
* than the Database class directly. This is to allow for alternate user
* databases e.g. LDAP, although not such support for alternatives exists yet.
*/
class Database {
/** The version number of the latest schema the interface is aware of */
@ -275,6 +279,10 @@ class Database {
return true;
}
/** Renames a user
*
* This does not have an effect on their numeric ID, but has a cascading effect on many tables
*/
public function userRename(string $user, string $name): bool {
if ($user === $name) {
return false;
@ -328,6 +336,11 @@ class Database {
return true;
}
/** Retrieves any metadata associated with a user
*
* @param string $user The user whose metadata is to be retrieved
* @param bool $includeLarge Whether to include values which can be arbitrarily large text
*/
public function userPropertiesGet(string $user, bool $includeLarge = true): array {
$basic = $this->db->prepare("SELECT num, admin from arsse_users where id = ?", "str")->run($user)->getRow();
if (!$basic) {
@ -345,6 +358,11 @@ class Database {
return $meta;
}
/** Set one or more metadata properties for a user
*
* @param string $user The user whose metadata is to be sedt
* @param array $data An associative array of property names and values
*/
public function userPropertiesSet(string $user, array $data): bool {
if (!$this->userExists($user)) {
throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
@ -529,22 +547,27 @@ class Database {
// check to make sure the parent exists, if one is specified
$parent = $this->folderValidateId($user, $parent)['id'];
$q = new Query(
"SELECT
"WITH RECURSIVE
folders as (
select id from arsse_folders where owner = ? and coalesce(parent,0) = ? union all select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id
)
select
id,
name,
arsse_folders.parent as parent,
coalesce(children,0) as children,
coalesce(feeds,0) as feeds
FROM arsse_folders
left join (SELECT parent,count(id) as children from arsse_folders group by parent) as child_stats on child_stats.parent = arsse_folders.id
left join (SELECT folder,count(id) as feeds from arsse_subscriptions group by folder) as sub_stats on sub_stats.folder = arsse_folders.id"
from arsse_folders
left join (select parent,count(id) as children from arsse_folders group by parent) as child_stats on child_stats.parent = arsse_folders.id
left join (select folder,count(id) as feeds from arsse_subscriptions group by folder) as sub_stats on sub_stats.folder = arsse_folders.id",
["str", "strict int"],
[$user, $parent]
);
if (!$recursive) {
$q->setWhere("owner = ?", "str", $user);
$q->setWhere("coalesce(arsse_folders.parent,0) = ?", "strict int", $parent);
} else {
$q->setCTE("folders", "SELECT id from arsse_folders where owner = ? and coalesce(parent,0) = ? union all select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id", ["str", "strict int"], [$user, $parent]);
$q->setWhere("id in (SELECT id from folders)");
$q->setWhere("id in (select id from folders)");
}
$q->setOrder("name");
return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
@ -679,14 +702,14 @@ class Database {
$p = $this->db->prepareArray(
"WITH RECURSIVE
target as (
SELECT ? as userid, ? as source, ? as dest, ? as new_name
select ? as userid, ? as source, ? as dest, ? as new_name
),
folders as (
SELECT id from arsse_folders join target on owner = userid and coalesce(parent,0) = source
select id from arsse_folders join target on owner = userid and coalesce(parent,0) = source
union all
select arsse_folders.id as id from arsse_folders join folders on arsse_folders.parent=folders.id
)
SELECT
select
case when
((select dest from target) is null or exists(select id from arsse_folders join target on owner = userid and coalesce(id,0) = coalesce(dest,0)))
then 1 else 0 end as extant,
@ -793,7 +816,14 @@ class Database {
// create a complex query
$integer = $this->db->sqlToken("integer");
$q = new Query(
"SELECT
"WITH RECURSIVE
topmost(f_id, top) as (
select id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id
),
folders(folder) as (
select ? union all select id from arsse_folders join folders on parent = folder
)
select
s.id as id,
s.feed as feed,
f.url,source,pinned,err_count,err_msg,order_type,added,keep_rule,block_rule,f.etag,s.scrape,
@ -806,7 +836,7 @@ class Database {
folder, t.top as top_folder, d.name as folder_name, dt.name as top_folder_name,
coalesce(s.title, f.title) as title,
coalesce((articles - hidden - marked), coalesce(articles,0)) as unread
FROM arsse_subscriptions as s
from arsse_subscriptions as s
join arsse_feeds as f on f.id = s.feed
left join topmost as t on t.f_id = s.folder
left join arsse_folders as d on s.folder = d.id
@ -825,21 +855,19 @@ class Database {
sum(hidden) as hidden,
sum(cast((\"read\" = 1 and hidden = 0) as $integer)) as marked
from arsse_marks group by subscription
) as mark_stats on mark_stats.subscription = s.id"
) as mark_stats on mark_stats.subscription = s.id",
["str", "int"],
[$user, $folder]
);
$q->setWhere("s.owner = ?", ["str"], [$user]);
$nocase = $this->db->sqlToken("nocase");
$q->setOrder("pinned desc, coalesce(s.title, f.title) collate $nocase");
// topmost folders belonging to the user
$q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id", ["str"], [$user]);
if ($id) {
// if an ID is specified, add a suitable WHERE condition and bindings
// this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder
$q->setWhere("s.id = ?", "int", $id);
} elseif ($folder && $recursive) {
// if a folder is specified and we're listing recursively, 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 all select id from arsse_folders join folders on parent = folder", "int", $folder);
// add a suitable WHERE condition
// if a folder is specified and we're listing recursively, add a suitable WHERE condition
$q->setWhere("folder in (select folder from folders)");
} elseif (!$recursive) {
// if we're not listing recursively, match against only the specified folder (even if it is null)
@ -857,12 +885,18 @@ class Database {
// validate inputs
$folder = $this->folderValidateId($user, $folder)['id'];
// create a complex query
$q = new Query("SELECT count(*) from arsse_subscriptions");
$q = new Query(
"WITH RECURSIVE
folders(folder) as (
select ? union all select id from arsse_folders join folders on parent = folder
)
select count(*) from arsse_subscriptions",
["int"],
[$folder]
);
$q->setWhere("owner = ?", "str", $user);
if ($folder) {
// if the specified folder exists, 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 all select id from arsse_folders join folders on parent = folder", "int", $folder);
// add a suitable WHERE condition
// if the specified folder exists, add a suitable WHERE condition
$q->setWhere("folder in (select folder from folders)");
}
return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
@ -1449,6 +1483,7 @@ class Database {
*/
protected function articleColumns(): array {
$greatest = $this->db->sqlToken("greatest");
$least = $this->db->sqlToken("least");
return [
'id' => "arsse_articles.id", // The article's unchanging numeric ID
'edition' => "latest_editions.edition", // The article's numeric ID which increases each time it is modified in the feed
@ -1468,6 +1503,8 @@ class Database {
'hidden' => "coalesce(arsse_marks.hidden,0)", // Whether the article is hidden
'starred' => "coalesce(arsse_marks.starred,0)", // Whether the article is starred
'unread' => "abs(coalesce(arsse_marks.read,0) - 1)", // Whether the article is unread
'labelled' => "$least(coalesce(label_stats.assigned,0),1)", // Whether the article has at least one label
'annotated' => "(case when coalesce(arsse_marks.note,'') <> '' then 1 else 0 end)", // Whether the article has a note
'note' => "coalesce(arsse_marks.note,'')", // The article's note, if any
'published_date' => "arsse_articles.published", // The date at which the article was first published i.e. its creation date
'edited_date' => "arsse_articles.edited", // The date at which the article was last edited according to the feed
@ -1484,33 +1521,11 @@ class Database {
* If an empty column list is supplied, a count of articles matching the context is queried instead
*
* @param string $user The user whose articles are to be queried
* @param Context $context The search context
* @param RootContext $context The search context
* @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
protected function articleQuery(string $user, RootContext $context, array $cols = ["id"]): Query {
// prepare the output column list; the column definitions are also used for ordering
$colDefs = $this->articleColumns();
if (!$cols) {
// if no columns are specified return a count; don't borther with sorting
@ -1534,7 +1549,23 @@ class Database {
assert(strlen($outColumns) > 0, new \Exception("No input columns matched whitelist"));
// define the basic query, to which we add lots of stuff where necessary
$q = new Query(
"SELECT
"WITH RECURSIVE
folders(id,req) as (
select 0, 0 union all select f.id, f.id from arsse_folders as f where owner = ? union all select f1.id, req from arsse_folders as f1 join folders on coalesce(parent,0)=folders.id
),
folders_top(id,top) as (
select f.id, f.id from arsse_folders as f where owner = ? and parent is null union all select f.id, top from arsse_folders as f join folders_top as t on parent=t.id
),
folder_data(id,name,top,top_name) as (
select f1.id, f1.name, top, f2.name from arsse_folders as f1 join folders_top as f0 on f1.id = f0.id join arsse_folders as f2 on f2.id = top
),
labelled(article,label_id,label_name) as (
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
),
tagged(subscription,tag_id,tag_name) as (
select m.subscription, t.id, t.name from arsse_tag_members as m join arsse_tags as t on t.id = m.tag where t.owner = ? and m.assigned = 1
)
select
$outColumns
from arsse_articles
join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed and arsse_subscriptions.owner = ?
@ -1543,68 +1574,133 @@ class Database {
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
join (
SELECT article, max(id) as edition from arsse_editions group by article
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
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]
["str", "str", "str", "str", "str", "str"],
[$user, $user, $user, $user, $user, $user]
);
$q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id", ["str"], [$user]);
$q->setCTE("folder_data(id,name,top,top_name)", "SELECT f1.id, f1.name, top, f2.name from arsse_folders as f1 join topmost on f1.id = f_id join arsse_folders as f2 on f2.id = top");
$q->setLimit($context->limit, $context->offset);
if ($context instanceof UnionContext) {
// if the context is a union context, we compute each context in turn
$q->setWhereRestrictive(false);
foreach ($context as $c) {
$q->setWhereGroup($this->articleFilter($c));
}
} else {
// if the context is not a union, first validate input to catch 404s and the like
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);
}
// ensure any used array-type context options contain at least one member
foreach ([
"articles",
"editions",
"subscriptions",
"folders",
"foldersShallow",
"labels",
"labelNames",
"tags",
"tagNames",
"searchTerms",
"titleTerms",
"authorTerms",
"annotationTerms",
"modifiedRanges",
"markedRanges",
] as $m) {
if ($context->$m() && !$context->$m) {
throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]);
}
}
// next compute the context, supplying the query to manipulate directly
$this->articleFilter($context, $q);
}
// return the query
return $q;
}
protected function articleFilter(Context $context, QueryFilter $q = null) {
$q = $q ?? new QueryFilter;
$colDefs = $this->articleColumns();
// handle the simple context options
$options = [
// each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an option to pair with for BETWEEN evaluation
"edition" => ["edition", "=", "int", ""],
"editions" => ["edition", "in", "int", ""],
"article" => ["id", "=", "int", ""],
"articles" => ["id", "in", "int", ""],
"oldestArticle" => ["id", ">=", "int", "latestArticle"],
"latestArticle" => ["id", "<=", "int", "oldestArticle"],
"oldestEdition" => ["edition", ">=", "int", "latestEdition"],
"latestEdition" => ["edition", "<=", "int", "oldestEdition"],
"modifiedSince" => ["modified_date", ">=", "datetime", "notModifiedSince"],
"notModifiedSince" => ["modified_date", "<=", "datetime", "modifiedSince"],
"markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince"],
"notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince"],
"folderShallow" => ["folder", "=", "int", ""],
"foldersShallow" => ["folder", "in", "int", ""],
"subscription" => ["subscription", "=", "int", ""],
"subscriptions" => ["subscription", "in", "int", ""],
"unread" => ["unread", "=", "bool", ""],
"starred" => ["starred", "=", "bool", ""],
"hidden" => ["hidden", "=", "bool", ""],
// each context array consists of a column identifier (see $colDefs above), a comparison operator, and a data type; the "between" operator has special handling
"edition" => ["edition", "=", "int"],
"editions" => ["edition", "in", "int"],
"article" => ["id", "=", "int"],
"articles" => ["id", "in", "int"],
"articleRange" => ["id", "between", "int"],
"editionRange" => ["edition", "between", "int"],
"modifiedRange" => ["modified_date", "between", "datetime"],
"markedRange" => ["marked_date", "between", "datetime"],
"folderShallow" => ["folder", "=", "int"],
"foldersShallow" => ["folder", "in", "int"],
"subscription" => ["subscription", "=", "int"],
"subscriptions" => ["subscription", "in", "int"],
"unread" => ["unread", "=", "bool"],
"starred" => ["starred", "=", "bool"],
"hidden" => ["hidden", "=", "bool"],
"labelled" => ["labelled", "=", "bool"],
"annotated" => ["annotated", "=", "bool"],
];
foreach ($options as $m => [$col, $op, $type, $pair]) {
if (!$context->$m()) {
// context is not being used
continue;
foreach ($options as $m => [$col, $op, $type]) {
if ($context->$m()) {
if ($op === "between") {
// option is a range
if ($context->$m[0] === null) {
// range is open at the low end
$q->setWhere("{$colDefs[$col]} <= ?", $type, $context->$m[1]);
} elseif ($context->$m[1] === null) {
// range is open at the high end
$q->setWhere("{$colDefs[$col]} >= ?", $type, $context->$m[0]);
} else {
// range is bounded in both directions
$q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $context->$m);
}
} 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
}
[$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 {
$q->setWhere("{$colDefs[$col]} $op ?", $type, $context->$m);
}
}
// further handle exclusionary options if specified
foreach ($options as $m => [$col, $op, $type, $pair]) {
if (!method_exists($context->not, $m) || !$context->not->$m()) {
// context option is not being used
continue;
// handle the exclusionary version
if (method_exists($context->not, $m) && $context->not->$m()) {
if ($op === "between") {
// option is a range
if ($context->not->$m[0] === null) {
// range is open at the low end
$q->setWhereNot("{$colDefs[$col]} <= ?", $type, $context->not->$m[1]);
} elseif ($context->not->$m[1] === null) {
// range is open at the high end
$q->setWhereNot("{$colDefs[$col]} >= ?", $type, $context->not->$m[0]);
} else {
// range is bounded in both directions
$q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $context->not->$m);
}
} elseif (is_array($context->not->$m)) {
if (!$context->not->$m) {
// for exclusions we don't care if the array is empty
@ -1612,126 +1708,47 @@ class Database {
}
[$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);
}
}
// handle labels and tags
}
// handle folder trees, labels, and tags
$options = [
'label' => [
'match_col' => "arsse_articles.id",
'cte_name' => "labelled",
'cte_cols' => ["article", "label_id", "label_name"],
'cte_body' => "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",
'cte_types' => ["str"],
'cte_values' => [$user],
'options' => [
'label' => ['use_name' => false, 'multi' => false],
'labels' => ['use_name' => false, 'multi' => true],
'labelName' => ['use_name' => true, 'multi' => false],
'labelNames' => ['use_name' => true, 'multi' => true],
],
],
'tag' => [
'match_col' => "arsse_subscriptions.id",
'cte_name' => "tagged",
'cte_cols' => ["subscription", "tag_id", "tag_name"],
'cte_body' => "SELECT m.subscription, t.id, t.name from arsse_tag_members as m join arsse_tags as t on t.id = m.tag where t.owner = ? and m.assigned = 1",
'cte_types' => ["str"],
'cte_values' => [$user],
'options' => [
'tag' => ['use_name' => false, 'multi' => false],
'tags' => ['use_name' => false, 'multi' => true],
'tagName' => ['use_name' => true, 'multi' => false],
'tagNames' => ['use_name' => true, 'multi' => true],
],
],
// each context array consists of a common table expression to select from, the column to match in the main join, the column to match in the CTE, the column to select in the CTE, an operator, and a type for the match in the CTE
'folder' => ["folders", "folder", "folders.id", "req", "=", "int"],
'folders' => ["folders", "folder", "folders.id", "req", "in", "int"],
'label' => ["labelled", "id", "labelled.article", "label_id", "=", "int"],
'labels' => ["labelled", "id", "labelled.article", "label_id", "in", "int"],
'labelName' => ["labelled", "id", "labelled.article", "label_name", "=", "str"],
'labelNames' => ["labelled", "id", "labelled.article", "label_name", "in", "str"],
'tag' => ["tagged", "subscription", "tagged.subscription", "tag_id", "=", "int"],
'tags' => ["tagged", "subscription", "tagged.subscription", "tag_id", "in", "int"],
'tagName' => ["tagged", "subscription", "tagged.subscription", "tag_name", "=", "str"],
'tagNames' => ["tagged", "subscription", "tagged.subscription", "tag_name", "in", "str"],
];
foreach ($options as $opt) {
$seen = false;
$match = $opt['match_col'];
$table = $opt['cte_name'];
foreach ($opt['options'] as $m => $props) {
$named = $props['use_name'];
$multi = $props['multi'];
$selection = $opt['cte_cols'][0];
$col = $opt['cte_cols'][$named ? 2 : 1];
foreach ($options as $m => [$cte, $outerCol, $selection, $innerCol, $op, $type]) {
if ($context->$m()) {
$seen = true;
if (!$context->$m) {
throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element
}
if ($multi) {
[$test, $types, $values] = $this->generateIn($context->$m, $named ? "str" : "int");
$test = "in ($test)";
if ($op === "in") {
[$inClause, $inTypes, $inValues] = $this->generateIn($context->$m, $type);
$q->setWhere("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol in($inClause))", $inTypes, $inValues);
} else {
$test = "= ?";
$types = $named ? "str" : "int";
$values = $context->$m;
$q->setWhere("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol = ?)", $type, $context->$m);
}
$q->setWhere("$match in (select $selection from $table where $col $test)", $types, $values);
}
// handle the exclusionary version
if ($context->not->$m()) {
$seen = true;
if ($multi) {
[$test, $types, $values] = $this->generateIn($context->not->$m, $named ? "str" : "int");
$test = "in ($test)";
if ($op === "in") {
if (!$context->not->$m) {
// for exclusions we don't care if the array is empty
continue;
}
[$inClause, $inTypes, $inValues] = $this->generateIn($context->not->$m, $type);
$q->setWhereNot("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol in($inClause))", $inTypes, $inValues);
} else {
$test = "= ?";
$types = $named ? "str" : "int";
$values = $context->not->$m;
}
$q->setWhereNot("$match in (select $selection from $table where $col $test)", $types, $values);
$q->setWhereNot("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol = ?)", $type, $context->not->$m);
}
}
if ($seen) {
$spec = $opt['cte_name']."(".implode(",", $opt['cte_cols']).")";
$q->setCTE($spec, $opt['cte_body'], $opt['cte_types'], $opt['cte_values']);
}
}
// 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->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 all select id from arsse_folders join folders on coalesce(parent,0) = folder", "int", $context->folder);
// limit subscriptions to the listed folders
$q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders)");
}
if ($context->folders()) {
[$inClause, $inTypes, $inValues] = $this->generateIn($context->folders, "int");
// add a common table expression to list the folders and their children so that we select from the entire subtree
$q->setCTE("folders_multi(folder)", "SELECT id as folder from (select id from (select 0 as id union all select id from arsse_folders where owner = ?) as f where id in ($inClause)) as folders_multi union select id from arsse_folders join folders_multi on coalesce(parent,0) = folder", ["str", $inTypes], [$user, $inValues]);
// limit subscriptions to the listed folders
$q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi)");
}
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 all select id from arsse_folders join folders_excluded on coalesce(parent,0) = 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)");
}
if ($context->not->folders()) {
[$inClause, $inTypes, $inValues] = $this->generateIn($context->not->folders, "int");
// add a common table expression to list the folders and their children so that we select from the entire subtree
$q->setCTE("folders_multi_excluded(folder)", "SELECT id as folder from (select id from (select 0 as id union all select id from arsse_folders where owner = ?) as f where id in ($inClause)) as folders_multi_excluded union select id from arsse_folders join folders_multi_excluded on coalesce(parent,0) = folder", ["str", $inTypes], [$user, $inValues]);
// limit subscriptions to the listed folders
$q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi_excluded)");
}
// handle text-matching context options
$options = [
@ -1741,29 +1758,56 @@ class Database {
"annotationTerms" => ["note"],
];
foreach ($options as $m => $columns) {
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
}
$columns = array_map(function($c) use ($colDefs) {
assert(isset($colDefs[$c]), new Exception("constantUnknown", $c));
return $colDefs[$c];
}, $columns);
if ($context->$m()) {
$q->setWhere(...$this->generateSearch($context->$m, $columns));
}
// further handle exclusionary text-matching context options
foreach ($options as $m => $columns) {
if (!$context->not->$m() || !$context->not->$m) {
continue;
}
$columns = array_map(function($c) use ($colDefs) {
assert(isset($colDefs[$c]), new Exception("constantUnknown", $c));
return $colDefs[$c];
}, $columns);
// handle the exclusionary version
if ($context->not->$m() && $context->not->$m) {
$q->setWhereNot(...$this->generateSearch($context->not->$m, $columns, true));
}
// return the query
}
// handle arrays of ranges
$options = [
'modifiedRanges' => ["modified_date", "datetime"],
'markedRanges' => ["marked_date", "datetime"],
];
foreach ($options as $m => [$col, $type]) {
if ($context->$m()) {
$subq = (new QueryFilter)->setWhereRestrictive(false);
foreach ($context->$m as $r) {
if ($r[0] === null) {
// range is open at the low end
$subq->setWhere("{$colDefs[$col]} <= ?", $type, $r[1]);
} elseif ($r[1] === null) {
// range is open at the high end
$subq->setWhere("{$colDefs[$col]} >= ?", $type, $r[0]);
} else {
// range is bounded in both directions
$subq->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $r);
}
}
$q->setWhereGroup($subq);
}
// handle the exclusionary version
if ($context->not->$m() && $context->not->$m) {
foreach ($context->not->$m as $r) {
if ($r[0] === null) {
// range is open at the low end
$q->setWhereNot("{$colDefs[$col]} <= ?", $type, $r[1]);
} elseif ($r[1] === null) {
// range is open at the high end
$q->setWhereNot("{$colDefs[$col]} >= ?", $type, $r[0]);
} else {
// range is bounded in both directions
$q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $r);
}
}
}
}
return $q;
}
@ -1772,11 +1816,11 @@ class Database {
* If an empty column list is supplied, a count of articles is returned instead
*
* @param string $user The user whose articles are to be listed
* @param Context $context The search context
* @param RootContext $context The search context
* @param array $fieldss 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
* @param array $sort The columns to sort the result by eg. "edition desc" in decreasing order of importance
*/
public function articleList(string $user, Context $context = null, array $fields = ["id"], array $sort = []): Db\Result {
public function articleList(string $user, RootContext $context = null, array $fields = ["id"], array $sort = []): Db\Result {
// make a base query based on context and output columns
$context = $context ?? new Context;
$q = $this->articleQuery($user, $context, $fields);
@ -1818,9 +1862,9 @@ class Database {
/** Returns a count of articles which match the given query context
*
* @param string $user The user whose articles are to be counted
* @param Context $context The search context
* @param RootContext $context The search context
*/
public function articleCount(string $user, Context $context = null): int {
public function articleCount(string $user, RootContext $context = null): int {
$context = $context ?? new Context;
$q = $this->articleQuery($user, $context, []);
return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
@ -1837,10 +1881,10 @@ class Database {
*
* @param string $user The user who owns the articles to be modified
* @param array $data An associative array of properties to modify. Anything not specified will remain unchanged
* @param Context $context The query context to match articles against
* @param RootContext $context The query context to match articles against
* @param bool $updateTimestamp Whether to also update the timestamp. This should only be false if a mark is changed as a result of an automated action not taken by the user
*/
public function articleMark(string $user, array $data, Context $context = null, bool $updateTimestamp = true): int {
public function articleMark(string $user, array $data, RootContext $context = null, bool $updateTimestamp = true): int {
$data = [
'read' => $data['read'] ?? null,
'starred' => $data['starred'] ?? null,
@ -1865,10 +1909,23 @@ class Database {
// marking as read is ignored if the edition is not the latest, but the same is not true of the other two marks
$this->db->query("UPDATE arsse_marks set touched = 0 where touched <> 0");
// set read marks
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
$q->setWhere("arsse_marks.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']);
$q->pushCTE("target_articles(article,subscription)");
$q->setBody("UPDATE arsse_marks set \"read\" = ?, touched = 1 where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", "bool", $data['read']);
$subq = $this->articleQuery($user, $context, ["id", "subscription"]);
$subq->setWhere("arsse_marks.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']);
$q = new Query(
"WITH RECURSIVE
target_articles(article, subscription) as (
{$subq->getQuery()}
)
update arsse_marks
set
\"read\" = ?,
touched = 1
where
article in (select article from target_articles)
and subscription in (select distinct subscription from target_articles)",
[$subq->getTypes(), "bool"],
[$subq->getValues(), $data['read']]
);
$this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
// get the articles associated with the requested editions
if ($context->edition()) {
@ -1878,14 +1935,27 @@ class Database {
}
// set starred, hidden, and/or note marks (unless all requested editions actually do not exist)
if ($context->article || $context->articles) {
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
$q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['hidden']]);
$q->pushCTE("target_articles(article,subscription)");
$data = array_filter($data, function($v) {
$setData = array_filter($data, function($v) {
return isset($v);
});
[$set, $setTypes, $setValues] = $this->generateSet($data, ['starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
$q->setBody("UPDATE arsse_marks set touched = 1, $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues);
[$set, $setTypes, $setValues] = $this->generateSet($setData, ['starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
$subq = $this->articleQuery($user, $context, ["id", "subscription"]);
$subq->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['hidden']]);
$q = new Query(
"WITH RECURSIVE
target_articles(article, subscription) as (
{$subq->getQuery()}
)
update arsse_marks
set
touched = 1,
$set
where
article in (select article from target_articles)
and subscription in (select distinct subscription from target_articles)",
[$subq->getTypes(), $setTypes],
[$subq->getValues(), $setValues]
);
$this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
}
// finally set the modification date for all touched marks and return the number of affected marks
@ -1906,17 +1976,29 @@ class Database {
return 0;
}
}
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
$q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool", "bool"], [$data['note'], $data['starred'], $data['read'], $data['hidden']]);
$q->pushCTE("target_articles(article,subscription)");
$data = array_filter($data, function($v) {
$setData = array_filter($data, function($v) {
return isset($v);
});
[$set, $setTypes, $setValues] = $this->generateSet($data, ['read' => "bool", 'starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
[$set, $setTypes, $setValues] = $this->generateSet($setData, ['read' => "bool", 'starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
if ($updateTimestamp) {
$set .= ", modified = CURRENT_TIMESTAMP";
}
$q->setBody("UPDATE arsse_marks set $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues);
$subq = $this->articleQuery($user, $context, ["id", "subscription"]);
$subq->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool", "bool"], [$data['note'], $data['starred'], $data['read'], $data['hidden']]);
$q = new Query(
"WITH RECURSIVE
target_articles(article, subscription) as (
{$subq->getQuery()}
)
update arsse_marks
set
$set
where
article in (select article from target_articles)
and subscription in (select distinct subscription from target_articles)",
[$subq->getTypes(), $setTypes],
[$subq->getValues(), $setValues]
);
$out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes();
}
$tr->commit();
@ -2086,7 +2168,7 @@ class Database {
}
/** Returns the numeric identifier of the most recent edition of an article matching the given context */
public function editionLatest(string $user, Context $context = null): int {
public function editionLatest(string $user, RootContext $context = null): int {
$context = $context ?? new Context;
$q = $this->articleQuery($user, $context, ["latest_edition"]);
return (int) $this->db->prepare((string) $q, $q->getTypes())->run($q->getValues())->getValue();
@ -2289,11 +2371,11 @@ class Database {
*
* @param string $user The owner of the label
* @param integer|string $id The numeric identifier or name of the label
* @param Context $context The query context matching the desired articles
* @param RootContext $context The query context matching the desired articles
* @param int $mode Whether to add (ASSOC_ADD), remove (ASSOC_REMOVE), or replace with (ASSOC_REPLACE) the matching associations
* @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false)
*/
public function labelArticlesSet(string $user, $id, Context $context, int $mode = self::ASSOC_ADD, bool $byName = false): int {
public function labelArticlesSet(string $user, $id, RootContext $context, int $mode = self::ASSOC_ADD, bool $byName = false): int {
assert(in_array($mode, [self::ASSOC_ADD, self::ASSOC_REMOVE, self::ASSOC_REPLACE]), new Exception("constantUnknown", $mode));
// validate the tag ID, and get the numeric ID if matching by name
$id = $this->labelValidateId($user, $id, $byName, true)['id'];

View file

@ -224,7 +224,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
public function maintenance(): bool {
// with MySQL each table must be analyzed separately, so we first have to get a list of tables
foreach ($this->query("SHOW TABLES like 'arsse\\_%'") as $table) {
foreach ($this->query("SHOW TABLES like 'arsse%'") as $table) {
$table = array_pop($table);
if (!preg_match("/^arsse_[a-z_]+$/D", $table)) {
// table is not one of ours

View file

@ -15,7 +15,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
self::T_DATETIME => "s",
self::T_BINARY => "b",
self::T_STRING => "s",
self::T_BOOLEAN => "i",
self::T_BOOLEAN => "i", // NOTE: Integers are used rather than booleans so that they may be manipulated arithmetically
];
protected $db;

View file

@ -15,7 +15,7 @@ abstract class PDOStatement extends AbstractStatement {
self::T_DATETIME => \PDO::PARAM_STR,
self::T_BINARY => \PDO::PARAM_LOB,
self::T_STRING => \PDO::PARAM_STR,
self::T_BOOLEAN => \PDO::PARAM_INT, // FIXME: using \PDO::PARAM_BOOL leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3
self::T_BOOLEAN => \PDO::PARAM_INT, // NOTE: Integers are used rather than booleans so that they may be manipulated arithmetically
];
protected $st;

View file

@ -15,7 +15,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
self::T_DATETIME => "timestamp(0) without time zone",
self::T_BINARY => "bytea",
self::T_STRING => "text",
self::T_BOOLEAN => "smallint", // FIXME: using boolean leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3
self::T_BOOLEAN => "smallint", // NOTE: Integers are used rather than booleans so that they may be manipulated arithmetically
];
protected $db;

View file

@ -120,6 +120,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
switch (strtolower($token)) {
case "greatest":
return "max";
case "least":
return "min";
case "asc":
return "";
default:

View file

@ -18,7 +18,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
self::T_DATETIME => \SQLITE3_TEXT,
self::T_BINARY => \SQLITE3_BLOB,
self::T_STRING => \SQLITE3_TEXT,
self::T_BOOLEAN => \SQLITE3_INTEGER,
self::T_BOOLEAN => \SQLITE3_INTEGER, // NOTE: Integers are used rather than booleans so that they may be manipulated arithmetically
];
protected $db;

View file

@ -6,19 +6,10 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\Misc;
class Query {
class Query extends QueryFilter {
protected $qBody = ""; // main query body
protected $tBody = []; // main query parameter types
protected $vBody = []; // main query parameter values
protected $qCTE = []; // Common table expression query components
protected $tCTE = []; // Common table expression type bindings
protected $vCTE = []; // Common table expression binding values
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;
@ -37,33 +28,6 @@ class Query {
return $this;
}
public function setCTE(string $tableSpec, string $body, $types = null, $values = null): self {
$this->qCTE[] = "$tableSpec as ($body)";
if (!is_null($types)) {
$this->tCTE[] = $types;
$this->vCTE[] = $values;
}
return $this;
}
public function setWhere(string $where, $types = null, $values = null): self {
$this->qWhere[] = $where;
if (!is_null($types)) {
$this->tWhere[] = $types;
$this->vWhere[] = $values;
}
return $this;
}
public function setWhereNot(string $where, $types = null, $values = null): self {
$this->qWhereNot[] = $where;
if (!is_null($types)) {
$this->tWhereNot[] = $types;
$this->vWhereNot[] = $values;
}
return $this;
}
public function setGroup(string ...$column): self {
foreach ($column as $col) {
$this->group[] = $col;
@ -84,33 +48,8 @@ class Query {
return $this;
}
public function pushCTE(string $tableSpec): self {
// 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->tWhereNot], [$this->vBody, $this->vWhere, $this->vWhereNot]);
$this->tBody = [];
$this->vBody = [];
$this->qWhere = [];
$this->tWhere = [];
$this->vWhere = [];
$this->qWhereNot = [];
$this->tWhereNot = [];
$this->vWhereNot = [];
$this->order = [];
$this->group = [];
$this->setLimit(0, 0);
return $this;
}
public function __toString(): string {
$out = "";
if (sizeof($this->qCTE)) {
// start with common table expressions
$out .= "WITH RECURSIVE ".implode(", ", $this->qCTE)." ";
}
// add the body
$out .= $this->buildQueryBody();
return $out;
return $this->buildQueryBody();
}
public function getQuery(): string {
@ -118,11 +57,11 @@ class Query {
}
public function getTypes(): array {
return ValueInfo::flatten([$this->tCTE, $this->tBody, $this->tWhere, $this->tWhereNot]);
return ValueInfo::flatten([$this->tBody, $this->getWhereTypes()]);
}
public function getValues(): array {
return ValueInfo::flatten([$this->vCTE, $this->vBody, $this->vWhere, $this->vWhereNot]);
return ValueInfo::flatten([$this->vBody, $this->getWhereValues()]);
}
protected function buildQueryBody(): string {
@ -131,11 +70,7 @@ class Query {
$out .= $this->qBody;
// add any WHERE terms
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";
$out .= " WHERE ".$this->buildWhereBody();
}
// add any GROUP BY terms
if (sizeof($this->group)) {

75
lib/Misc/QueryFilter.php Normal file
View file

@ -0,0 +1,75 @@
<?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\Misc;
class QueryFilter {
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 $filterRestrictive = true; // Whether to glue WHERE conditions with OR (false) or AND (true)
public function setWhere(string $where, $types = null, $values = null): self {
$this->qWhere[] = $where;
if (!is_null($types)) {
$this->tWhere[] = $types ?? [];
$this->vWhere[] = $values;
}
return $this;
}
public function setWhereNot(string $where, $types = null, $values = null): self {
$this->qWhereNot[] = $where;
if (!is_null($types)) {
$this->tWhereNot[] = $types;
$this->vWhereNot[] = $values;
}
return $this;
}
public function setWhereGroup(self $filter): self {
$this->qWhere[] = "(".$filter->buildWhereBody().")";
$this->tWhere[] = $filter->getWhereTypes();
$this->vWhere[] = $filter->getWhereValues();
return $this;
}
public function setWhereRestrictive(bool $restrictive): self {
$this->filterRestrictive = $restrictive;
return $this;
}
protected function getWhereTypes(): array {
return ValueInfo::flatten([$this->tWhere, $this->tWhereNot]);
}
protected function getWhereValues(): array {
return ValueInfo::flatten([$this->vWhere, $this->vWhereNot]);
}
public function getTypes(): array {
return $this->getWhereTypes();
}
public function getValues(): array {
return $this->getWhereValues();
}
protected function buildWhereBody(): string {
$glue = $this->filterRestrictive ? " AND " : " OR ";
$where = implode($glue, $this->qWhere);
$whereNot = implode(" OR ", $this->qWhereNot);
$whereNot = strlen($whereNot) ? "NOT ($whereNot)" : "";
return implode($glue, array_filter([$where, $whereNot]));
}
public function __toString() {
return $this->buildWhereBody();
}
}

View file

@ -281,15 +281,15 @@ class ValueInfo {
if (!$out) {
throw new \Exception;
}
return $out;
return $out->setTimezone(new \DateTimeZone("UTC"));
} else {
return new \DateTimeImmutable($value, new \DateTimeZone("UTC"));
// if the string fails to parse it will produce an exception which is caught just below
return (new \DateTimeImmutable($value, new \DateTimeZone("UTC")))->setTimezone(new \DateTimeZone("UTC"));
}
} catch (\Exception $e) {
if ($strict && !$drop) {
throw new ExceptionType("strictFailure", $type);
}
return null;
}
} elseif ($strict && !$drop) {
throw new ExceptionType("strictFailure", $type);

View file

@ -244,7 +244,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
$c = new Context;
$id = $P['id'];
if ($P['before']) {
$c->notMarkedSince($P['before']);
$c->markedRange(null, $P['before']);
}
switch ($P['mark']) {
case "item":
@ -310,7 +310,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
$c = (new Context)->hidden(false);
$lastUnread = Date::normalize($lastUnread, "sql");
$since = Date::sub("PT15S", $lastUnread);
$c->unread(false)->markedSince($since);
$c->unread(false)->markedRange($since, null);
Arsse::$db->articleMark(Arsse::$user->id, ['read' => false], $c);
}
@ -388,10 +388,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
if ($G['with_ids']) {
$c->articles(explode(",", $G['with_ids']))->hidden(null);
} elseif ($G['max_id']) {
$c->latestArticle($G['max_id'] - 1);
$c->articleRange(null, $G['max_id'] - 1);
$reverse = true;
} elseif ($G['since_id']) {
$c->oldestArticle($G['since_id'] + 1);
$c->articleRange($G['since_id'] + 1, null);
}
// handle the undocumented options
if ($G['group_ids']) {

View file

@ -12,6 +12,8 @@ use JKingWeb\Arsse\ExceptionType;
use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Context\UnionContext;
use JKingWeb\Arsse\Context\RootContext;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\ImportExport\OPML;
use JKingWeb\Arsse\ImportExport\Exception as ImportException;
@ -644,9 +646,10 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
*
* - "num": The user's numeric ID,
* - "root": The effective name of the root folder
* - "tz": The time zone preference of the user, or UTC if not set
*/
protected function userMeta(string $user): array {
$meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
$meta = Arsse::$user->propertiesGet($user, false);
return [
'num' => $meta['num'],
'root' => $meta['root_folder_name'] ?? Arsse::$lang->msg("API.Miniflux.DefaultCategoryName"),
@ -689,7 +692,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
if ($folder === 0) {
// folder 0 doesn't actually exist in the database, so its name is kept as user metadata
if (!strlen(trim($title))) {
throw new ExceptionInput("whitespace");
throw new ExceptionInput("whitespace", ['field' => "title", 'action' => __FUNCTION__]);
}
$title = Arsse::$user->propertiesSet(Arsse::$user->id, ['root_folder_name' => $title])['root_folder_name'];
} else {
@ -885,18 +888,15 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
]);
}
protected function computeContext(array $query, Context $c = null): Context {
protected function computeContext(array $query, Context $c): RootContext {
if ($query['before'] && $query['before']->getTimestamp() === 0) {
$query['before'] = null; // NOTE: This workaround is needed for compatibility with "Microflux for Miniflux", an Android Client
}
$c = ($c ?? new Context)
->limit($query['limit'] ?? self::DEFAULT_ENTRY_LIMIT) // NOTE: This does not honour user preferences
$c->limit($query['limit'] ?? self::DEFAULT_ENTRY_LIMIT) // NOTE: This does not honour user preferences
->offset($query['offset'])
->starred($query['starred'])
->modifiedSince($query['after']) // FIXME: This may not be the correct date field
->notModifiedSince($query['before'])
->oldestArticle($query['after_entry_id'] ? $query['after_entry_id'] + 1 : null) // FIXME: This might be edition
->latestArticle($query['before_entry_id'] ? $query['before_entry_id'] - 1 : null)
->modifiedRange($query['after'], $query['before']) // FIXME: This may not be the correct date field
->articleRange($query['after_entry_id'] ? $query['after_entry_id'] + 1 : null, $query['before_entry_id'] ? $query['before_entry_id'] - 1 : null) // FIXME: This might be edition
->searchTerms(strlen($query['search'] ?? "") ? preg_split("/\s+/", $query['search']) : null); // NOTE: Miniflux matches only whole words; we match simple substrings
if ($query['category_id']) {
if ($query['category_id'] === 1) {
@ -905,17 +905,20 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
$c->folder($query['category_id'] - 1);
}
}
// FIXME: specifying e.g. ?status=read&status=removed should yield all hidden articles and all read articles, but the best we can do is all read articles which are or are not hidden
$status = array_unique($query['status']);
sort($status);
if ($status === ["read", "removed"]) {
$c->unread(false);
$c1 = $c;
$c2 = clone $c;
$c = new UnionContext($c1->unread(false), $c2->hidden(true));
} elseif ($status === ["read", "unread"]) {
$c->hidden(false);
} elseif ($status === ["read"]) {
$c->hidden(false)->unread(false);
} elseif ($status === ["removed", "unread"]) {
$c->unread(true);
$c1 = $c;
$c2 = clone $c;
$c = new UnionContext($c1->unread(true), $c2->hidden(true));
} elseif ($status === ["removed"]) {
$c->hidden(true);
} elseif ($status === ["unread"]) {
@ -1025,7 +1028,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
// find the entry we want
$entry = Arsse::$db->articleList(Arsse::$user->id, $c, self::ARTICLE_COLUMNS)->getRow();
if (!$entry) {
throw new ExceptionInput("idMissing");
throw new ExceptionInput("idMissing", ['id' => $id, 'field' => 'entry']);
}
$out = $this->transformEntry($entry, $meta['num'], $meta['tz']);
// next transform the parent feed of the entry

View file

@ -346,7 +346,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
// build the context
$c = (new Context)->hidden(false);
$c->latestEdition((int) $data['newestItemId']);
$c->editionRange(null, (int) $data['newestItemId']);
$c->folder((int) $url[1]);
// perform the operation
try {
@ -501,7 +501,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
// build the context
$c = (new Context)->hidden(false);
$c->latestEdition((int) $data['newestItemId']);
$c->editionRange(null, (int) $data['newestItemId']);
$c->subscription((int) $url[1]);
// perform the operation
try {
@ -526,9 +526,9 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// set the edition mark-off; the database uses an or-equal comparison for internal consistency, but the protocol does not, so we must adjust by one
if ($data['offset'] > 0) {
if ($reverse) {
$c->latestEdition($data['offset'] - 1);
$c->editionRange(null, $data['offset'] - 1);
} else {
$c->oldestEdition($data['offset'] + 1);
$c->editionRange($data['offset'] + 1, null);
}
}
// set whether to only return unread
@ -556,7 +556,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
// whether to return only updated items
if ($data['lastModified']) {
$c->markedSince($data['lastModified']);
$c->markedRange($data['lastModified'], null);
}
// perform the fetch
try {
@ -597,7 +597,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
// build the context
$c = (new Context)->hidden(false);
$c->latestEdition((int) $data['newestItemId']);
$c->editionRange(null, (int) $data['newestItemId']);
// perform the operation
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
return new EmptyResponse(204);

View file

@ -256,7 +256,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
public function opGetCounters(array $data): array {
$user = Arsse::$user->id;
$starred = Arsse::$db->articleStarred($user);
$fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H", $this->now()))->hidden(false));
$fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedRange(Date::sub("PT24H", $this->now()), null)->hidden(false));
$countAll = 0;
$countSubs = 0;
$feeds = [];
@ -361,7 +361,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
'id' => "FEED:".self::FEED_FRESH,
'bare_id' => self::FEED_FRESH,
'icon' => "images/fresh.png",
'unread' => Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H", $this->now()))->hidden(false)),
'unread' => Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedRange(Date::sub("PT24H", $this->now()), null)->hidden(false)),
], $tSpecial),
array_merge([ // Starred articles
'name' => Arsse::$lang->msg("API.TTRSS.Feed.Starred"),
@ -545,7 +545,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// FIXME: this is pretty inefficient
$f = $map[self::CAT_SPECIAL];
$cats[$f]['unread'] += Arsse::$db->articleStarred($user)['unread']; // starred
$cats[$f]['unread'] += Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H", $this->now()))->hidden(false)); // fresh
$cats[$f]['unread'] += Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedRange(Date::sub("PT24H", $this->now()), null)->hidden(false)); // fresh
if (!$read) {
// if we're only including unread entries, remove any categories with zero unread items (this will by definition also exclude empties)
$count = sizeof($cats);
@ -697,7 +697,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
if ($cat == self::CAT_ALL || $cat == self::CAT_SPECIAL) {
// gather some statistics
$starred = Arsse::$db->articleStarred($user)['unread'];
$fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H", $this->now()))->hidden(false));
$fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedRange(Date::sub("PT24H", $this->now()), null)->hidden(false));
$global = Arsse::$db->articleCount($user, (new Context)->unread(true)->hidden(false));
$published = 0; // TODO: if the Published feed is implemented, the getFeeds method needs to be adjusted accordingly
$archived = 0; // the archived feed is non-functional in the TT-RSS protocol itself
@ -1096,7 +1096,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// TODO: if the Published feed is implemented, the catchup function needs to be modified accordingly
return $out;
case self::FEED_FRESH:
$c->modifiedSince(Date::sub("PT24H", $this->now()));
$c->modifiedRange(Date::sub("PT24H", $this->now()), null);
break;
case self::FEED_ALL:
// no context needed here
@ -1112,13 +1112,13 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
switch ($mode) {
case "2week":
$c->notModifiedSince(Date::sub("P2W", $this->now()));
$c->modifiedRange($c->modifiedRange[0], Date::sub("P2W", $this->now()));
break;
case "1week":
$c->notModifiedSince(Date::sub("P1W", $this->now()));
$c->modifiedRange($c->modifiedRange[0], Date::sub("P1W", $this->now()));
break;
case "1day":
$c->notModifiedSince(Date::sub("PT24H", $this->now()));
$c->modifiedRange($c->modifiedRange[0], Date::sub("PT24H", $this->now()));
}
// perform the marking
try {
@ -1473,13 +1473,13 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// TODO: if the Published feed is implemented, the headline function needs to be modified accordingly
return new ResultEmpty;
case self::FEED_FRESH:
$c->modifiedSince(Date::sub("PT24H", $this->now()))->unread(true);
$c->modifiedRange(Date::sub("PT24H", $this->now()), null)->unread(true);
break;
case self::FEED_ALL:
// no context needed here
break;
case self::FEED_READ:
$c->markedSince(Date::sub("PT24H", $this->now()))->unread(false); // FIXME: this selects any recently touched (read, starred, annotated) article which is read, not necessarily a recently read one
$c->markedRange(Date::sub("PT24H", $this->now()), null)->unread(false); // FIXME: this selects any recently touched (read, starred, annotated) article which is read, not necessarily a recently read one
break;
default:
// any actual feed
@ -1520,7 +1520,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
// handle the search string, if any
if (isset($data['search'])) {
$c = Search::parse($data['search'], $c);
$tz = Arsse::$user->propertiesGet(Arsse::$user->id, false)['tz'] ?? "UTC";
$c = Search::parse($data['search'], $tz, $c);
if (!$c) {
// the search string inherently returns an empty result, either directly or interacting with other input
return new ResultEmpty;
@ -1550,7 +1551,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
// set the minimum article ID
if ($data['since_id'] > 0) {
$c->oldestArticle($data['since_id'] + 1);
$c->articleRange($data['since_id'] + 1, null);
}
// return results
return Arsse::$db->articleList(Arsse::$user->id, $c, $fields, $order);

View file

@ -32,7 +32,7 @@ class Search {
"" => "searchTerms",
];
public static function parse(string $search, Context $context = null): ?Context {
public static function parse(string $search, string $tz, Context $context = null): ?Context {
// normalize the input
$search = strtolower(trim(preg_replace("<\s+>", " ", $search)));
// set initial state
@ -88,7 +88,7 @@ class Search {
continue 3;
case '"':
if (($pos + 1 == $stop) || $search[$pos + 1] === " ") {
$context = self::processToken($context, $buffer, $tag, $flag_negative, false);
$context = self::processToken($context, $buffer, $tag, $flag_negative);
$state = self::STATE_BEFORE_TOKEN;
$flag_negative = false;
$buffer = $tag = "";
@ -135,7 +135,7 @@ class Search {
while ($pos < $stop && $search[$pos] !== " ") {
$buffer .= $search[$pos++];
}
$context = self::processToken($context, $buffer, $tag, $flag_negative, true);
$context = self::processToken($context, $buffer, $tag, $flag_negative, $tz);
$state = self::STATE_BEFORE_TOKEN;
$flag_negative = false;
$buffer = $tag = "";
@ -145,7 +145,7 @@ class Search {
case "":
case '"':
if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") {
$context = self::processToken($context, $buffer, $tag, $flag_negative, true);
$context = self::processToken($context, $buffer, $tag, $flag_negative, $tz);
$state = self::STATE_BEFORE_TOKEN;
$flag_negative = false;
$buffer = $tag = "";
@ -178,7 +178,7 @@ class Search {
if (!strlen($tag)) {
$buffer = ":".$buffer;
}
$context = self::processToken($context, $buffer, $tag, $flag_negative, false);
$context = self::processToken($context, $buffer, $tag, $flag_negative);
$state = self::STATE_BEFORE_TOKEN;
$flag_negative = false;
$buffer = $tag = "";
@ -191,7 +191,7 @@ class Search {
if (!strlen($tag)) {
$buffer = ":".$buffer;
}
$context = self::processToken($context, $buffer, $tag, $flag_negative, false);
$context = self::processToken($context, $buffer, $tag, $flag_negative);
$state = self::STATE_BEFORE_TOKEN;
$flag_negative = false;
$buffer = $tag = "";
@ -221,7 +221,7 @@ class Search {
switch ($char) {
case "":
case " ":
$context = self::processToken($context, $buffer, $tag, $flag_negative, false);
$context = self::processToken($context, $buffer, $tag, $flag_negative);
$state = self::STATE_BEFORE_TOKEN;
$flag_negative = false;
$buffer = $tag = "";
@ -241,7 +241,7 @@ class Search {
case "":
case '"':
if (($pos + 1 >= $stop) || $search[$pos + 1] === " ") {
$context = self::processToken($context, $buffer, $tag, $flag_negative, false);
$context = self::processToken($context, $buffer, $tag, $flag_negative);
$state = self::STATE_BEFORE_TOKEN;
$flag_negative = false;
$buffer = $tag = "";
@ -282,7 +282,7 @@ class Search {
return $context;
}
protected static function processToken(Context $c, string $value, string $tag, bool $neg, bool $date): Context {
protected static function processToken(Context $c, string $value, string $tag, bool $neg, string $tz = null): Context {
if (!strlen($value) && !strlen($tag)) {
return $c;
} elseif (!strlen($value)) {
@ -290,8 +290,8 @@ class Search {
$value = "$tag:";
$tag = "";
}
if ($date) {
return self::setDate($value, $c, $neg);
if ($tz !== null) {
return self::setDate($value, $c, $neg, $tz);
} elseif (isset(self::FIELDS_BOOLEAN[$tag])) {
return self::setBoolean($tag, $value, $c, $neg);
} else {
@ -309,28 +309,18 @@ class Search {
return $c->$type(array_merge($c->$type ?? [], [$value]));
}
protected static function setDate(string $value, Context $c, bool $neg): Context {
protected static function setDate(string $value, Context $c, bool $neg, string $tz): 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
$start = $day."T00:00:00 $tz";
$end = $day."T23:59:59 $tz";
$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;
// NOTE: TTRSS treats multiple positive dates as contradictory; we instead treat them as complimentary instead, because it makes more sense
return $cc->modifiedRanges(array_merge($cc->modifiedRanges, [[$start, $end]]));
}
protected static function setBoolean(string $tag, string $value, Context $c, bool $neg): Context {

View file

@ -16,7 +16,7 @@ create table arsse_users(
avatar_type text, -- internal avatar image's MIME content type
avatar_data blob, -- internal avatar image's binary data
admin boolean default 0, -- whether the user is a member of the special "admin" group
rights integer not null default 0 -- temporary admin-rights marker FIXME: remove reliance on this
rights integer not null default 0 -- temporary admin-rights marker
);
create table arsse_users_meta(

View file

@ -13,7 +13,7 @@ create table arsse_users_new(
avatar_type text, -- internal avatar image's MIME content type
avatar_data blob, -- internal avatar image's binary data
admin boolean default 0, -- whether the user is a member of the special "admin" group
rights integer not null default 0 -- temporary admin-rights marker FIXME: remove reliance on this
rights integer not null default 0 -- temporary admin-rights marker
);
insert into arsse_users_new(id,password,name,avatar_type,avatar_data,admin,rights) select id,password,name,avatar_type,avatar_data,admin,rights from arsse_users;
drop table arsse_users;

View file

@ -9,6 +9,8 @@ namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Context\UnionContext;
use JKingWeb\Arsse\Context\RootContext;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\ValueInfo;
@ -423,7 +425,7 @@ trait SeriesArticle {
}
/** @dataProvider provideContextMatches */
public function testListArticlesCheckingContext(Context $c, array $exp): void {
public function testListArticlesCheckingContext(RootContext $c, array $exp): void {
$ids = array_column($ids = Arsse::$db->articleList("john.doe@example.com", $c, ["id"], ["id"])->getAll(), "id");
sort($ids);
sort($exp);
@ -456,23 +458,23 @@ trait SeriesArticle {
'Not hidden' => [(new Context)->hidden(false), [1,2,3,4,5,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]],
'Not after edition 999' => [(new Context)->subscription(5)->editionRange(null, 999), [19]],
'Not after edition 19' => [(new Context)->subscription(5)->editionRange(null, 19), [19]],
'Not before edition 999' => [(new Context)->subscription(5)->editionRange(999, null), [20]],
'Not before edition 1001' => [(new Context)->subscription(5)->editionRange(1001, null), [20]],
'Not after article 3' => [(new Context)->articleRange(null, 3), [1,2,3]],
'Not before article 19' => [(new Context)->articleRange(19, null), [19,20]],
'Modified by author since 2005' => [(new Context)->modifiedRange("2005-01-01T00:00:00Z", null), [2,4,6,8,20]],
'Modified by author since 2010' => [(new Context)->modifiedRange("2010-01-01T00:00:00Z", null), [2,4,6,8,20]],
'Not modified by author since 2005' => [(new Context)->modifiedRange(null, "2005-01-01T00:00:00Z"), [1,3,5,7,19]],
'Not modified by author since 2000' => [(new Context)->modifiedRange(null, "2000-01-01T00:00:00Z"), [1,3,5,7,19]],
'Marked or labelled since 2014' => [(new Context)->markedRange("2014-01-01T00:00:00Z", null), [8,19]],
'Marked or labelled since 2010' => [(new Context)->markedRange("2010-01-01T00:00:00Z", null), [2,4,6,8,19,20]],
'Not marked or labelled since 2014' => [(new Context)->markedRange(null, "2014-01-01T00:00:00Z"), [1,2,3,4,5,6,7,20]],
'Not marked or labelled since 2005' => [(new Context)->markedRange(null, "2005-01-01T00:00:00Z"), [1,3,5,7]],
'Marked or labelled between 2000 and 2015' => [(new Context)->markedRange("2000-01-01T00:00:00Z", "2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]],
'Marked or labelled in 2010' => [(new Context)->markedRange("2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"), [2,4,6,20]],
'Paged results' => [(new Context)->limit(2)->editionRange(4, null), [4,5]],
'With label ID 1' => [(new Context)->label(1), [1,19]],
'With label ID 2' => [(new Context)->label(2), [1,5,20]],
'With label ID 1 or 2' => [(new Context)->labels([1,2]), [1,5,19,20]],
@ -505,7 +507,9 @@ trait SeriesArticle {
'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]],
'Folder tree 1 excluding no labels' => [(new Context)->folder(1)->not->labels([]), [5,6,7,8]],
'Folder tree 1 excluding no tags' => [(new Context)->folder(1)->not->tags([]), [5,6,7,8]],
'Marked or labelled between 2000 and 2015 excluding in 2010' => [(new Context)->markedRange("2000-01-01T00:00:00Z", "2015-12-31T23:59:59")->not->markedRange("2010-01-01T00:00:00Z", "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]],
@ -526,6 +530,17 @@ trait SeriesArticle {
'Excluding entire folder tree' => [(new Context)->not->folder(0), []],
'Excluding multiple folder trees' => [(new Context)->not->folders([1,5]), [1,2,3,4]],
'Excluding multiple folder trees including root' => [(new Context)->not->folders([0,1,5]), []],
'Before article 3' => [(new Context)->not->articleRange(3, null), [1,2]],
'Before article 19' => [(new Context)->not->articleRange(null, 19), [20]],
'Marked or labelled in 2010 or 2015' => [(new Context)->markedRanges([["2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", "2015-12-31T23:59:59Z"]]), [2,4,6,8,20]],
'Not marked or labelled in 2010 or 2015' => [(new Context)->not->markedRanges([["2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", "2015-12-31T23:59:59Z"]]), [1,3,5,7,19]],
'Marked or labelled prior to 2010 or since 2015' => [(new Context)->markedRanges([[null, "2009-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", null]]), [1,3,5,7,8,19]],
'Not marked or labelled prior to 2010 or since 2015' => [(new Context)->not->markedRanges([[null, "2009-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", null]]), [2,4,6,20]],
'Modified in 2010 or 2015' => [(new Context)->modifiedRanges([["2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", "2015-12-31T23:59:59Z"]]), [2,4,6,8,20]],
'Not modified in 2010 or 2015' => [(new Context)->not->modifiedRanges([["2010-01-01T00:00:00Z", "2010-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", "2015-12-31T23:59:59Z"]]), [1,3,5,7,19]],
'Modified prior to 2010 or since 2015' => [(new Context)->modifiedRanges([[null, "2009-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", null]]), [1,3,5,7,19]],
'Not modified prior to 2010 or since 2015' => [(new Context)->not->modifiedRanges([[null, "2009-12-31T23:59:59Z"], ["2015-01-01T00:00:00Z", null]]), [2,4,6,8,20]],
'Either read or hidden' => [(new UnionContext((new Context)->unread(false), (new Context)->hidden(true))), [1, 6, 19]],
];
}
@ -929,7 +944,7 @@ trait SeriesArticle {
}
public function testMarkByOldestEdition(): void {
Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->oldestEdition(19));
Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->editionRange(19, null));
$now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables);
$state['arsse_marks']['rows'][8][3] = 1;
@ -940,7 +955,7 @@ trait SeriesArticle {
}
public function testMarkByLatestEdition(): void {
Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->latestEdition(20));
Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->editionRange(null, 20));
$now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables);
$state['arsse_marks']['rows'][8][3] = 1;
@ -953,7 +968,7 @@ trait SeriesArticle {
}
public function testMarkByLastMarked(): void {
Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->markedSince('2017-01-01T00:00:00Z'));
Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->markedRange('2017-01-01T00:00:00Z', null));
$now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables);
$state['arsse_marks']['rows'][8][3] = 1;
@ -964,7 +979,7 @@ trait SeriesArticle {
}
public function testMarkByNotLastMarked(): void {
Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->notMarkedSince('2000-01-01T00:00:00Z'));
Arsse::$db->articleMark($this->user, ['starred' => true], (new Context)->markedRange(null, '2000-01-01T00:00:00Z'));
$now = Date::transform(time(), "sql");
$state = $this->primeExpectations($this->data, $this->checkTables);
$state['arsse_marks']['rows'][] = [13,5,0,1,$now,'',0];
@ -1035,9 +1050,10 @@ trait SeriesArticle {
public function provideArrayContextOptions(): iterable {
foreach ([
"articles", "editions",
"subscriptions", "foldersShallow", //"folders",
"subscriptions", "foldersShallow", "folders",
"tags", "tagNames", "labels", "labelNames",
"searchTerms", "authorTerms", "annotationTerms",
"modifiedRanges", "markedRanges",
] as $method) {
yield [$method];
}

View file

@ -387,9 +387,11 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest {
$integer = $this->drv->sqlToken("InTEGer");
$asc = $this->drv->sqlToken("asc");
$desc = $this->drv->sqlToken("desc");
$least = $this->drv->sqlToken("leASt");
$this->assertSame("NOT_A_TOKEN", $this->drv->sqlToken("NOT_A_TOKEN"));
$this->assertSame("A", $this->drv->query("SELECT $least('Z', 'A')")->getValue());
$this->assertSame("Z", $this->drv->query("SELECT $greatest('Z', 'A')")->getValue());
$this->assertSame("Z", $this->drv->query("SELECT 'Z' collate $nocase")->getValue());
$this->assertSame("Z", $this->drv->query("SELECT 'Z' where 'Z' $like 'z'")->getValue());

View file

@ -7,82 +7,97 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Misc;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Context\ExclusionContext;
use JKingWeb\Arsse\Context\UnionContext;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\ValueInfo;
/** @covers \JKingWeb\Arsse\Context\Context<extended> */
/**
* @covers \JKingWeb\Arsse\Context\Context<extended>
* @covers \JKingWeb\Arsse\Context\ExclusionContext<extended>
* @covers \JKingWeb\Arsse\Context\UnionContext<extended>
*/
class TestContext extends \JKingWeb\Arsse\Test\AbstractTest {
public function testVerifyInitialState(): void {
$c = new Context;
foreach ((new \ReflectionObject($c))->getMethods(\ReflectionMethod::IS_PUBLIC) as $m) {
if ($m->isStatic() || strpos($m->name, "__") === 0) {
continue;
}
$method = $m->name;
$this->assertFalse($c->$method(), "Context method $method did not initially return false");
$this->assertEquals(null, $c->$method, "Context property $method is not initially falsy");
}
}
protected $ranges = ['modifiedRange', 'markedRange', 'articleRange', 'editionRange'];
protected $times = ['modifiedRange', 'markedRange'];
public function testSetContextOptions(): void {
$v = [
'reverse' => true,
'limit' => 10,
'offset' => 5,
'folder' => 42,
'folders' => [12,22],
'folderShallow' => 42,
'foldersShallow' => [0,1],
'tag' => 44,
'tags' => [44, 2112],
'tagName' => "XLIV",
'tagNames' => ["XLIV", "MMCXII"],
'subscription' => 2112,
'subscriptions' => [44, 2112],
'article' => 255,
'edition' => 65535,
'latestArticle' => 47,
'oldestArticle' => 1337,
'latestEdition' => 47,
'oldestEdition' => 1337,
'unread' => true,
'starred' => true,
'hidden' => true,
'modifiedSince' => new \DateTime(),
'notModifiedSince' => new \DateTime(),
'markedSince' => new \DateTime(),
'notMarkedSince' => new \DateTime(),
'editions' => [1,2],
'articles' => [1,2],
'label' => 2112,
'labels' => [2112, 1984],
'labelName' => "Rush",
'labelNames' => ["Rush", "Orwell"],
'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->isStatic() || strpos($m->name, "__") === 0) {
continue;
}
$method = $m->name;
$this->assertArrayHasKey($method, $v, "Context method $method not included in test");
$this->assertInstanceOf(Context::class, $c->$method($v[$method]));
$this->assertTrue($c->$method());
if (in_array($method, $times)) {
$this->assertTime($c->$method, $v[$method], "Context method $method did not return the expected results");
/** @dataProvider provideContextOptions */
public function testSetContextOptions(string $method, array $input, $output, bool $not): void {
$parent = new Context;
$c = ($not) ? $parent->not : $parent;
$default = (new \ReflectionClass($c))->getDefaultProperties()[$method];
$this->assertFalse($c->$method(), "Context method did not initially return false");
if (in_array($method, $this->ranges)) {
$this->assertEquals([null, null], $c->$method, "Context property is not initially a two-member falsy array");
} else {
$this->assertSame($c->$method, $v[$method], "Context method $method did not return the expected results");
$this->assertFalse((bool) $c->$method, "Context property is not initially falsy");
}
$this->assertSame($parent, $c->$method(...$input), "Context method did not return the root after setting");
$this->assertTrue($c->$method());
if (in_array($method, $this->times)) {
if (is_array($default)) {
array_walk_recursive($c->$method, function(&$v, $k) {
if ($v !== null) {
$this->assertInstanceOf(\DateTimeImmutable::class, $v, "Context property contains an non-normalized date");
}
$v = ValueInfo::normalize($v, ValueInfo::T_STRING, null, "iso8601");
});
array_walk_recursive($output, function(&$v) {
$v = ValueInfo::normalize($v, ValueInfo::T_STRING, null, "iso8601");
});
$this->assertSame($c->$method, $output, "Context property did not return the expected results after setting");
} else {
$this->assertTime($c->$method, $output, "Context property did not return the expected results after setting");
}
} else {
$this->assertSame($c->$method, $output, "Context property did not return the expected results after setting");
}
// clear the context option
$c->$method(null);
$this->assertFalse($c->$method());
$c->$method(...array_fill(0, sizeof($input), null));
$this->assertFalse($c->$method(), "Context method did not return false after clearing");
}
public function provideContextOptions(): iterable {
$tests = [
'limit' => [[10], 10],
'offset' => [[5], 5],
'folder' => [[42], 42],
'folders' => [[[12,22]], [12,22]],
'folderShallow' => [[42], 42],
'foldersShallow' => [[[0,1]], [0,1]],
'tag' => [[44], 44],
'tags' => [[[44, 2112]], [44, 2112]],
'tagName' => [["XLIV"], "XLIV"],
'tagNames' => [[["XLIV", "MMCXII"]], ["XLIV", "MMCXII"]],
'subscription' => [[2112], 2112],
'subscriptions' => [[[44, 2112]], [44, 2112]],
'article' => [[255], 255],
'edition' => [[65535], 65535],
'unread' => [[true], true],
'starred' => [[true], true],
'hidden' => [[true], true],
'editions' => [[[1,2]], [1,2]],
'articles' => [[[1,2]], [1,2]],
'label' => [[2112], 2112],
'labels' => [[[2112, 1984]], [2112, 1984]],
'labelName' => [["Rush"], "Rush"],
'labelNames' => [[["Rush", "Orwell"]], ["Rush", "Orwell"]],
'labelled' => [[true], true],
'annotated' => [[true], true],
'searchTerms' => [[["foo", "bar"]], ["foo", "bar"]],
'annotationTerms' => [[["foo", "bar"]], ["foo", "bar"]],
'titleTerms' => [[["foo", "bar"]], ["foo", "bar"]],
'authorTerms' => [[["foo", "bar"]], ["foo", "bar"]],
'modifiedRange' => [["2020-03-06T22:08:03Z", "2022-12-31T06:33:12Z"], ["2020-03-06T22:08:03Z", "2022-12-31T06:33:12Z"]],
'markedRange' => [["2020-03-06T22:08:03Z", "2022-12-31T06:33:12Z"], ["2020-03-06T22:08:03Z", "2022-12-31T06:33:12Z"]],
'articleRange' => [[1, 100], [1, 100]],
'editionRange' => [[1, 100], [1, 100]],
];
foreach($tests as $k => $t) {
yield $k => array_merge([$k], $t, [false]);
if (method_exists(ExclusionContext::class, $k)) {
yield "$k (not)" => array_merge([$k], $t, [true]);
}
}
}
@ -117,6 +132,16 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest {
}
}
public function testCleanDateRangeArrayValues(): void {
$methods = ["modifiedRanges", "markedRanges"];
$in = [null, 1, [1, 2, 3], [1], [null, null], ["ook", null], ["2022-09-13T06:46:28 America/Los_angeles", new \DateTime("2022-01-23T00:33:49Z")], [0, null], [null, 0]];
$out = [[Date::normalize("2022-09-13T13:46:28Z"), Date::normalize("2022-01-23T00:33:49Z")], [Date::normalize(0), null], [null, Date::normalize(0)]];
$c = new Context;
foreach ($methods as $method) {
$this->assertEquals($out, $c->$method($in)->$method, "Context method $method did not return the expected results");
}
}
public function testCloneAContext(): void {
$c1 = new Context;
$c2 = clone $c1;
@ -127,4 +152,27 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertSame($c1, $c1->not->article(null));
$this->assertSame($c2, $c2->not->article(null));
}
public function testExerciseAUnionContext(): void {
$c1 = new UnionContext;
$c2 = new Context;
$c3 = new UnionContext;
$this->assertSame(0, sizeof($c1));
$c1[] = $c2;
$c1[2] = $c3;
$this->assertSame(2, sizeof($c1));
$this->assertSame($c2, $c1[0]);
$this->assertSame($c3, $c1[2]);
$this->assertSame(null, $c1[1]);
unset($c1[0]);
$this->assertFalse(isset($c1[0]));
$this->assertTrue(isset($c1[2]));
$c1[] = $c2;
$act = [];
foreach($c1 as $k => $v) {
$act[$k] = $v;
}
$exp = [2 => $c3, $c2];
$this->assertSame($exp, $act);
}
}

View file

@ -7,8 +7,12 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Misc;
use JKingWeb\Arsse\Misc\Query;
use JKingWeb\Arsse\Misc\QueryFilter;
/** @covers \JKingWeb\Arsse\Misc\Query */
/**
* @covers \JKingWeb\Arsse\Misc\Query
* @covers \JKingWeb\Arsse\Misc\QueryFilter
*/
class TestQuery extends \JKingWeb\Arsse\Test\AbstractTest {
public function testBasicQuery(): void {
$q = new Query("select * from table where a = ?", "int", 3);
@ -77,38 +81,35 @@ class TestQuery extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertSame([], $q->getValues());
}
public function testQueryWithCommonTableExpression(): void {
$q = (new Query("select * from table where a in (select * from cte where a = ?)", "int", 1))->setCTE("cte", "select * from other_table where a = ? and b = ?", ["str", "str"], [2, 3]);
$this->assertSame("WITH RECURSIVE cte as (select * from other_table where a = ? and b = ?) select * from table where a in (select * from cte where a = ?)", $q->getQuery());
$this->assertSame(["str", "str", "int"], $q->getTypes());
$this->assertSame([2, 3, 1], $q->getValues());
// multiple CTEs
$q = (new Query("select * from table where a in (select * from cte1 join cte2 using (a) where a = ?)", "int", 1))->setCTE("cte1", "select * from other_table where a = ? and b = ?", ["str", "str"], [2, 3])->setCTE("cte2", "select * from other_table where c between ? and ?", ["datetime", "datetime"], [4, 5]);
$this->assertSame("WITH RECURSIVE cte1 as (select * from other_table where a = ? and b = ?), cte2 as (select * from other_table where c between ? and ?) select * from table where a in (select * from cte1 join cte2 using (a) where a = ?)", $q->getQuery());
$this->assertSame(["str", "str", "datetime", "datetime", "int"], $q->getTypes());
$this->assertSame([2, 3, 4, 5, 1], $q->getValues());
}
public function testQueryWithPushedCommonTableExpression(): void {
$q = (new Query("select * from table1"))->setWhere("a between ? and ?", ["datetime", "datetime"], [1, 2])
->setCTE("cte1", "select * from table2 where a = ? and b = ?", ["str", "str"], [3, 4])
->pushCTE("cte2")
->setBody("select * from table3 join cte1 using (a) join cte2 using (a) where a = ?", "int", 5);
$this->assertSame("WITH RECURSIVE cte1 as (select * from table2 where a = ? and b = ?), cte2 as (select * from table1 WHERE a between ? and ?) select * from table3 join cte1 using (a) join cte2 using (a) where a = ?", $q->getQuery());
$this->assertSame(["str", "str", "datetime", "datetime", "int"], $q->getTypes());
$this->assertSame([3, 4, 1, 2, 5], $q->getValues());
}
public function testComplexQuery(): void {
$q = (new query("select *, ? as const from table", "datetime", 1))
$q = (new query("SELECT *, ? as const from table", "datetime", 1))
->setWhereNot("b = ?", "bool", 2)
->setGroup("col1", "col2")
->setWhere("a = ?", "str", 3)
->setLimit(4, 5)
->setOrder("col3")
->setCTE("cte", "select ? as const", "int", 6);
$this->assertSame("WITH RECURSIVE cte as (select ? as const) select *, ? as const from table WHERE a = ? AND NOT (b = ?) GROUP BY col1, col2 ORDER BY col3 LIMIT 4 OFFSET 5", $q->getQuery());
$this->assertSame(["int", "datetime", "str", "bool"], $q->getTypes());
$this->assertSame([6, 1, 3, 2], $q->getValues());
->setOrder("col3");
$this->assertSame("SELECT *, ? as const from table WHERE a = ? AND NOT (b = ?) GROUP BY col1, col2 ORDER BY col3 LIMIT 4 OFFSET 5", $q->getQuery());
$this->assertSame(["datetime", "str", "bool"], $q->getTypes());
$this->assertSame([1, 3, 2], $q->getValues());
}
public function testNestedWhereConditions(): void {
$q = new Query("SELECT *, ? as const from table", "datetime", 1);
$f = new QueryFilter;
$f->setWhere("a = ?", "str", "ook")->setWhere("b = c")->setWhere("c = ?", "int", 42);
$this->assertSame("a = ? AND b = c AND c = ?", (string) $f);
$this->assertSame(["str", "int"], $f->getTypes());
$this->assertSame(["ook", 42], $f->getValues());
$q->setWhereGroup($f);
$f->setWhereRestrictive(false);
$this->assertSame("a = ? OR b = c OR c = ?", (string) $f);
$q->setWhereGroup($f);
$this->assertSame("SELECT *, ? as const from table WHERE (a = ? AND b = c AND c = ?) AND (a = ? OR b = c OR c = ?)", $q->getQuery());
$this->assertSame(["datetime", "str", "int", "str", "int"], $q->getTypes());
$this->assertSame([1, "ook", 42, "ook", 42], $q->getValues());
$q->setWhereRestrictive(false);
$this->assertSame("SELECT *, ? as const from table WHERE (a = ? AND b = c AND c = ?) OR (a = ? OR b = c OR c = ?)", $q->getQuery());
$this->assertSame(["datetime", "str", "int", "str", "int"], $q->getTypes());
$this->assertSame([1, "ook", 42, "ook", 42], $q->getValues());
}
}

View file

@ -349,7 +349,7 @@ class TestValueInfo extends \JKingWeb\Arsse\Test\AbstractTest {
// if we're performing a strict comparison and the value is supposed to fail, we should be getting an exception
$this->assertException("strictFailure", "", "ExceptionType");
I::normalize($input, $typeConst | $modeConst);
$this->assertTrue(false, "$typename $modeName test expected exception");
$this->assertTrue(false, "$typeName $modeName test expected exception");
} elseif ($drop && !$pass) {
// if we're performing a drop comparison and the value is supposed to fail, change the expectation to null
$exp = null;
@ -600,7 +600,7 @@ class TestValueInfo extends \JKingWeb\Arsse\Test\AbstractTest {
[[], null, null, null, null, null, null, null, null, null, null, null],
[$this->i("P1Y2D"), null, null, null, null, null, null, null, null, null, null, null],
["P1Y2D", null, null, null, null, null, null, null, null, null, null, null],
] as $set) {
] as $k => $set) {
// shift the input value off the set
$input = array_shift($set);
// generate a set of tests for each target date formats
@ -612,7 +612,7 @@ class TestValueInfo extends \JKingWeb\Arsse\Test\AbstractTest {
[false, true],
[true, true],
] as [$strict, $drop]) {
yield [$input, $formats[$format], $exp, $strict, $drop];
yield "Index #$k format \"$format\" strict:$strict drop:$drop" => [$input, $formats[$format], $exp, $strict, $drop];
}
}
}

View file

@ -316,12 +316,12 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
["items&group_ids=1,2,3,4", (clone $c)->tags([1,2,3,4])->hidden(false), false],
["items&feed_ids=1,2,3,4", (clone $c)->subscriptions([1,2,3,4])->hidden(false), false],
["items&with_ids=1,2,3,4", (clone $c)->articles([1,2,3,4]), false],
["items&since_id=1", (clone $c)->oldestArticle(2)->hidden(false), false],
["items&max_id=2", (clone $c)->latestArticle(1)->hidden(false), true],
["items&since_id=1", (clone $c)->articleRange(2, null)->hidden(false), false],
["items&max_id=2", (clone $c)->articleRange(null, 1)->hidden(false), true],
["items&with_ids=1,2,3,4&max_id=6", (clone $c)->articles([1,2,3,4]), false],
["items&with_ids=1,2,3,4&since_id=6", (clone $c)->articles([1,2,3,4]), false],
["items&max_id=3&since_id=6", (clone $c)->latestArticle(2)->hidden(false), true],
["items&feed_ids=1,2,3,4&since_id=6", (clone $c)->subscriptions([1,2,3,4])->oldestArticle(7)->hidden(false), false],
["items&max_id=3&since_id=6", (clone $c)->articleRange(null, 2)->hidden(false), true],
["items&feed_ids=1,2,3,4&since_id=6", (clone $c)->subscriptions([1,2,3,4])->articleRange(7, null)->hidden(false), false],
];
}
@ -407,7 +407,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
["mark=group&as=unread&id=-1", (new Context)->not->folder(0), $markUnread, $listUnread],
["mark=group&as=saved&id=-1", (new Context)->not->folder(0), $markSaved, $listSaved],
["mark=group&as=unsaved&id=-1", (new Context)->not->folder(0), $markUnsaved, $listSaved],
["mark=group&as=read&id=-1&before=946684800", (new Context)->not->folder(0)->notMarkedSince("2000-01-01T00:00:00Z"), $markRead, $listUnread],
["mark=group&as=read&id=-1&before=946684800", (new Context)->not->folder(0)->markedRange(null, "2000-01-01T00:00:00Z"), $markRead, $listUnread],
["mark=item&as=unread", new Context, [], []],
["mark=item&id=6", new Context, [], []],
["as=unread&id=6", new Context, [], []],
@ -462,7 +462,7 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
$this->dbMock->articleMark->returns(0);
$exp = new JsonResponse($out);
$this->assertMessage($exp, $this->req("api", ['unread_recently_read' => 1]));
$this->dbMock->articleMark->calledWith($this->userId, ['read' => false], $this->equalTo((new Context)->unread(false)->markedSince("1999-12-31T23:59:45Z")->hidden(false)));
$this->dbMock->articleMark->calledWith($this->userId, ['read' => false], $this->equalTo((new Context)->unread(false)->markedRange("1999-12-31T23:59:45Z", null)->hidden(false)));
$this->dbMock->articleList->with($this->userId, (new Context)->limit(1)->hidden(false), ["marked_date"], ["marked_date desc"])->returns(new Result([]));
$this->assertMessage($exp, $this->req("api", ['unread_recently_read' => 1]));
$this->dbMock->articleMark->once()->called(); // only called one time, above

View file

@ -10,6 +10,8 @@ use Eloquent\Phony\Mock\Handle\InstanceHandle;
use Eloquent\Phony\Phpunit\Phony;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Context\RootContext;
use JKingWeb\Arsse\Context\UnionContext;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\Transaction;
@ -711,7 +713,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
/** @dataProvider provideEntryQueries */
public function testGetEntries(string $url, ?Context $c, ?array $order, $out, bool $count, ResponseInterface $exp): void {
public function testGetEntries(string $url, ?RootContext $c, ?array $order, $out, bool $count, ResponseInterface $exp): void {
$this->dbMock->subscriptionList->returns(new Result($this->v(self::FEEDS)));
$this->dbMock->articleCount->returns(2112);
if ($out instanceof \Exception) {
@ -760,19 +762,20 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
["/entries?status=read", (clone $c)->unread(false)->hidden(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?status=removed", (clone $c)->hidden(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?status=unread&status=read", (clone $c)->hidden(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?status=unread&status=removed", (clone $c)->unread(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?status=removed&status=read", (clone $c)->unread(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?status=removed&status=read&status=removed", (clone $c)->unread(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?status=unread&status=removed", new UnionContext((clone $c)->unread(true), (clone $c)->hidden(true)), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?status=removed&status=read", new UnionContext((clone $c)->unread(false), (clone $c)->hidden(true)), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?status=removed&status=read&status=removed", new UnionContext((clone $c)->unread(false), (clone $c)->hidden(true)), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?status=removed&status=read&status=unread", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?starred", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?starred=", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?starred=true", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?starred=false", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?after=0", (clone $c)->modifiedSince(0), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?after=0", (clone $c)->modifiedRange(0, null), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?before=0", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?before=1", (clone $c)->notModifiedSince(1), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?after_entry_id=42", (clone $c)->oldestArticle(43), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?before_entry_id=47", (clone $c)->latestArticle(46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?before=1", (clone $c)->modifiedRange(null, 1), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?before=1&after=0", (clone $c)->modifiedRange(0, 1), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?after_entry_id=42", (clone $c)->articleRange(43, null), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?before_entry_id=47", (clone $c)->articleRange(null, 46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?search=alpha%20beta", (clone $c)->searchTerms(["alpha", "beta"]), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
["/entries?limit=4", (clone $c)->limit(4), $o, self::ENTRIES, true, new Response(['total' => 2112, 'entries' => self::ENTRIES_OUT])],
["/entries?offset=20", (clone $c)->offset(20), $o, [], true, new Response(['total' => 2112, 'entries' => []])],

View file

@ -695,9 +695,9 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
["/items", ['type' => 3, 'id' => 0], clone $c, $out, $r200],
["/items", ['getRead' => true], clone $c, $out, $r200],
["/items", ['getRead' => false], (clone $c)->unread(true), $out, $r200],
["/items", ['lastModified' => $t->getTimestamp()], (clone $c)->markedSince($t), $out, $r200],
["/items", ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5], (clone $c)->oldestEdition(6)->limit(10), $out, $r200],
["/items", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5], (clone $c)->latestEdition(4)->limit(5), $out, $r200],
["/items", ['lastModified' => $t->getTimestamp()], (clone $c)->markedRange($t, null), $out, $r200],
["/items", ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5], (clone $c)->editionRange(6, null)->limit(10), $out, $r200],
["/items", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5], (clone $c)->editionRange(null, 4)->limit(5), $out, $r200],
["/items", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], (clone $c)->limit(5), $out, $r200],
["/items/updated", [], clone $c, $out, $r200],
["/items/updated", ['type' => 0, 'id' => 42], (clone $c)->subscription(42), new ExceptionInput("idMissing"), $r422],
@ -708,9 +708,9 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
["/items/updated", ['type' => 3, 'id' => 0], clone $c, $out, $r200],
["/items/updated", ['getRead' => true], clone $c, $out, $r200],
["/items/updated", ['getRead' => false], (clone $c)->unread(true), $out, $r200],
["/items/updated", ['lastModified' => $t->getTimestamp()], (clone $c)->markedSince($t), $out, $r200],
["/items/updated", ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5], (clone $c)->oldestEdition(6)->limit(10), $out, $r200],
["/items/updated", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5], (clone $c)->latestEdition(4)->limit(5), $out, $r200],
["/items/updated", ['lastModified' => $t->getTimestamp()], (clone $c)->markedRange($t, null), $out, $r200],
["/items/updated", ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5], (clone $c)->editionRange(6, null)->limit(10), $out, $r200],
["/items/updated", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5], (clone $c)->editionRange(null, 4)->limit(5), $out, $r200],
["/items/updated", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], (clone $c)->limit(5), $out, $r200],
];
}
@ -718,8 +718,8 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
public function testMarkAFolderRead(): void {
$read = ['read' => true];
$in = json_encode(['newestItemId' => 2112]);
$this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->folder(1)->latestEdition(2112)->hidden(false)))->returns(42);
$this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->folder(42)->latestEdition(2112)->hidden(false)))->throws(new ExceptionInput("idMissing")); // folder doesn't exist
$this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->folder(1)->editionRange(null, 2112)->hidden(false)))->returns(42);
$this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->folder(42)->editionRange(null, 2112)->hidden(false)))->throws(new ExceptionInput("idMissing")); // folder doesn't exist
$exp = new EmptyResponse(204);
$this->assertMessage($exp, $this->req("PUT", "/folders/1/read", $in));
$this->assertMessage($exp, $this->req("PUT", "/folders/1/read?newestItemId=2112"));
@ -733,8 +733,8 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
public function testMarkASubscriptionRead(): void {
$read = ['read' => true];
$in = json_encode(['newestItemId' => 2112]);
$this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->subscription(1)->latestEdition(2112)->hidden(false)))->returns(42);
$this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->subscription(42)->latestEdition(2112)->hidden(false)))->throws(new ExceptionInput("idMissing")); // subscription doesn't exist
$this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->subscription(1)->editionRange(null, 2112)->hidden(false)))->returns(42);
$this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->subscription(42)->editionRange(null, 2112)->hidden(false)))->throws(new ExceptionInput("idMissing")); // subscription doesn't exist
$exp = new EmptyResponse(204);
$this->assertMessage($exp, $this->req("PUT", "/feeds/1/read", $in));
$this->assertMessage($exp, $this->req("PUT", "/feeds/1/read?newestItemId=2112"));
@ -748,7 +748,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
public function testMarkAllItemsRead(): void {
$read = ['read' => true];
$in = json_encode(['newestItemId' => 2112]);
$this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->latestEdition(2112)))->returns(42);
$this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->editionRange(null, 2112)))->returns(42);
$exp = new EmptyResponse(204);
$this->assertMessage($exp, $this->req("PUT", "/items/read", $in));
$this->assertMessage($exp, $this->req("PUT", "/items/read?newestItemId=2112"));

View file

@ -959,7 +959,7 @@ LONG_STRING;
$this->dbMock->folderList->with("~", null, false)->returns(new Result($this->v($this->topFolders)));
$this->dbMock->subscriptionList->returns(new Result($this->v($this->subscriptions)));
$this->dbMock->labelList->returns(new Result($this->v($this->labels)));
$this->dbMock->articleCount->with("~", $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedSince(Date::sub("PT24H", self::NOW))))->returns(7);
$this->dbMock->articleCount->with("~", $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedRange(Date::sub("PT24H", self::NOW), null)))->returns(7);
$this->dbMock->articleStarred->returns($this->v($this->starred));
$this->assertMessage($exp, $this->req($in));
}
@ -1060,7 +1060,7 @@ LONG_STRING;
['id' => -2, 'kind' => "cat", 'counter' => 6],
];
$this->assertMessage($this->respGood($exp), $this->req($in));
$this->dbMock->articleCount->calledWith($this->userId, $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedSince(Date::sub("PT24H", self::NOW))));
$this->dbMock->articleCount->calledWith($this->userId, $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedRange(Date::sub("PT24H", self::NOW), null)));
}
/** @dataProvider provideLabelListings */
@ -1152,7 +1152,7 @@ LONG_STRING;
$this->assertMessage($this->respGood($exp), $this->req($in[0]));
$exp = ['categories' => ['identifier' => 'id','label' => 'name','items' => [['name' => 'Special','id' => 'CAT:-1','bare_id' => -1,'type' => 'category','unread' => 0,'items' => [['name' => 'All articles','id' => 'FEED:-4','bare_id' => -4,'icon' => 'images/folder.png','unread' => 35,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Fresh articles','id' => 'FEED:-3','bare_id' => -3,'icon' => 'images/fresh.png','unread' => 7,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Starred articles','id' => 'FEED:-1','bare_id' => -1,'icon' => 'images/star.png','unread' => 4,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Published articles','id' => 'FEED:-2','bare_id' => -2,'icon' => 'images/feed.png','unread' => 0,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Archived articles','id' => 'FEED:0','bare_id' => 0,'icon' => 'images/archive.png','unread' => 0,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Recently read','id' => 'FEED:-6','bare_id' => -6,'icon' => 'images/time.png','unread' => 0,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => '']]],['name' => 'Labels','id' => 'CAT:-2','bare_id' => -2,'type' => 'category','unread' => 6,'items' => [['name' => 'Fascinating','id' => 'FEED:-1027','bare_id' => -1027,'unread' => 0,'icon' => 'images/label.png','type' => 'feed','auxcounter' => 0,'error' => '','updated' => '','fg_color' => '','bg_color' => ''],['name' => 'Interesting','id' => 'FEED:-1029','bare_id' => -1029,'unread' => 0,'icon' => 'images/label.png','type' => 'feed','auxcounter' => 0,'error' => '','updated' => '','fg_color' => '','bg_color' => ''],['name' => 'Logical','id' => 'FEED:-1025','bare_id' => -1025,'unread' => 0,'icon' => 'images/label.png','type' => 'feed','auxcounter' => 0,'error' => '','updated' => '','fg_color' => '','bg_color' => '']]],['name' => 'Politics','id' => 'CAT:3','bare_id' => 3,'parent_id' => null,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(3 feeds)','items' => [['name' => 'Local','id' => 'CAT:5','bare_id' => 5,'parent_id' => 3,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(1 feed)','items' => [['name' => 'Toronto Star','id' => 'FEED:2','bare_id' => 2,'icon' => 'feed-icons/2.ico','error' => 'oops','param' => '2011-11-11T11:11:11Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]],['name' => 'National','id' => 'CAT:6','bare_id' => 6,'parent_id' => 3,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(2 feeds)','items' => [['name' => 'CBC News','id' => 'FEED:4','bare_id' => 4,'icon' => 'feed-icons/4.ico','error' => '','param' => '2017-10-09T15:58:34Z','unread' => 0,'auxcounter' => 0,'checkbox' => false],['name' => 'Ottawa Citizen','id' => 'FEED:5','bare_id' => 5,'icon' => false,'error' => '','param' => '2017-07-07T17:07:17Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]]]],['name' => 'Science','id' => 'CAT:1','bare_id' => 1,'parent_id' => null,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(2 feeds)','items' => [['name' => 'Rocketry','id' => 'CAT:2','bare_id' => 2,'parent_id' => 1,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(1 feed)','items' => [['name' => 'NASA JPL','id' => 'FEED:1','bare_id' => 1,'icon' => false,'error' => '','param' => '2017-09-15T22:54:16Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]],['name' => 'Ars Technica','id' => 'FEED:3','bare_id' => 3,'icon' => 'feed-icons/3.ico','error' => 'argh','param' => '2016-05-23T06:40:02Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]],['name' => 'Uncategorized','id' => 'CAT:0','bare_id' => 0,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'parent_id' => null,'param' => '(1 feed)','items' => [['name' => 'Eurogamer','id' => 'FEED:6','bare_id' => 6,'icon' => 'feed-icons/6.ico','error' => '','param' => '2010-02-12T20:08:47Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]]]]];
$this->assertMessage($this->respGood($exp), $this->req($in[1]));
$this->dbMock->articleCount->twice()->calledWith($this->userId, $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedSince(Date::sub("PT24H", self::NOW))));
$this->dbMock->articleCount->twice()->calledWith($this->userId, $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedRange(Date::sub("PT24H", self::NOW), null)));
}
/** @dataProvider provideMassMarkings */
@ -1180,8 +1180,8 @@ LONG_STRING;
[['feed_id' => 0, 'is_cat' => true, 'mode' => "bogus"], (clone $c)->folderShallow(0)],
[['feed_id' => -1], (clone $c)->starred(true)],
[['feed_id' => -1, 'is_cat' => "t"], null],
[['feed_id' => -3], (clone $c)->modifiedSince(Date::sub("PT24H", self::NOW))],
[['feed_id' => -3, 'mode' => "1day"], (clone $c)->modifiedSince(Date::sub("PT24H", self::NOW))->notModifiedSince(Date::sub("PT24H", self::NOW))], // this is a nonsense query, but it's what TT-RSS appearsto do
[['feed_id' => -3], (clone $c)->modifiedRange(Date::sub("PT24H", self::NOW), null)],
[['feed_id' => -3, 'mode' => "1day"], (clone $c)->modifiedRange(Date::sub("PT24H", self::NOW), Date::sub("PT24H", self::NOW))], // this is a nonsense query, but it's what TT-RSS appearsto do
[['feed_id' => -3, 'is_cat' => true], null],
[['feed_id' => -2], null],
[['feed_id' => -2, 'is_cat' => true], (clone $c)->labelled(true)],
@ -1191,9 +1191,9 @@ LONG_STRING;
[['feed_id' => -6, 'is_cat' => "f"], null],
[['feed_id' => -2112], (clone $c)->label(1088)],
[['feed_id' => 42, 'is_cat' => true], (clone $c)->folder(42)],
[['feed_id' => 42, 'is_cat' => true, 'mode' => "1week"], (clone $c)->folder(42)->notModifiedSince(Date::sub("P1W", self::NOW))],
[['feed_id' => 42, 'is_cat' => true, 'mode' => "1week"], (clone $c)->folder(42)->modifiedRange(null, Date::sub("P1W", self::NOW))],
[['feed_id' => 2112], (clone $c)->subscription(2112)],
[['feed_id' => 2112, 'mode' => "2week"], (clone $c)->subscription(2112)->notModifiedSince(Date::sub("P2W", self::NOW))],
[['feed_id' => 2112, 'mode' => "2week"], (clone $c)->subscription(2112)->modifiedRange(null, Date::sub("P2W", self::NOW))],
];
}
@ -1202,7 +1202,7 @@ LONG_STRING;
$in = array_merge(['op' => "getFeeds", 'sid' => "PriestsOfSyrinx"], $in);
// statistical mocks
$this->dbMock->articleStarred->returns($this->v($this->starred));
$this->dbMock->articleCount->with("~", $this->equalTo((new Context)->unread(true)->hidden(false)->modifiedSince(Date::sub("PT24H", self::NOW))))->returns(7);
$this->dbMock->articleCount->with("~", $this->equalTo((new Context)->unread(true)->hidden(false)->modifiedRange(Date::sub("PT24H", self::NOW), null)))->returns(7);
$this->dbMock->articleCount->with("~", $this->equalTo((new Context)->unread(true)->hidden(false)))->returns(35);
// label mocks
$this->dbMock->labelList->returns(new Result($this->v($this->labels)));
@ -1488,6 +1488,84 @@ LONG_STRING;
];
}
/** @dataProvider provideArticleListingsWithoutLabels */
public function testListArticlesWithoutLabels(array $in, ResponseInterface $exp): void {
$in = array_merge(['op' => "getArticle", 'sid' => "PriestsOfSyrinx"], $in);
$this->dbMock->labelList->with("~")->returns(new Result([]));
$this->dbMock->labelList->with("~", false)->returns(new Result([]));
$this->dbMock->articleLabelsGet->with("~", 101)->returns([]);
$this->dbMock->articleLabelsGet->with("~", 102)->returns($this->v([1,3]));
$this->dbMock->articleList->with("~", $this->equalTo((new Context)->articles([101, 102])), "~")->returns(new Result($this->v($this->articles)));
$this->dbMock->articleList->with("~", $this->equalTo((new Context)->articles([101])), "~")->returns(new Result($this->v([$this->articles[0]])));
$this->dbMock->articleList->with("~", $this->equalTo((new Context)->articles([102])), "~")->returns(new Result($this->v([$this->articles[1]])));
$this->assertMessage($exp, $this->req($in));
}
public function provideArticleListingsWithoutLabels(): iterable {
$exp = [
[
'id' => "101",
'guid' => null,
'title' => 'Article title 1',
'link' => 'http://example.com/1',
'labels' => [],
'unread' => true,
'marked' => false,
'published' => false,
'comments' => "",
'author' => '',
'updated' => strtotime('2000-01-01T00:00:01Z'),
'feed_id' => "8",
'feed_title' => "Feed 11",
'attachments' => [],
'score' => 0,
'note' => null,
'lang' => "",
'content' => '<p>Article content 1</p>',
],
[
'id' => "102",
'guid' => "SHA256:5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7",
'title' => 'Article title 2',
'link' => 'http://example.com/2',
'labels' => [],
'unread' => false,
'marked' => false,
'published' => false,
'comments' => "",
'author' => "J. King",
'updated' => strtotime('2000-01-02T00:00:02Z'),
'feed_id' => "8",
'feed_title' => "Feed 11",
'attachments' => [
[
'id' => "0",
'content_url' => "http://example.com/text",
'content_type' => "text/plain",
'title' => "",
'duration' => "",
'width' => "",
'height' => "",
'post_id' => "102",
],
],
'score' => 0,
'note' => "Note 2",
'lang' => "",
'content' => '<p>Article content 2</p>',
],
];
return [
[[], $this->respErr("INCORRECT_USAGE")],
[['article_id' => 0], $this->respErr("INCORRECT_USAGE")],
[['article_id' => -1], $this->respErr("INCORRECT_USAGE")],
[['article_id' => "0,-1"], $this->respErr("INCORRECT_USAGE")],
[['article_id' => "101,102"], $this->respGood($exp)],
[['article_id' => "101"], $this->respGood([$exp[0]])],
[['article_id' => "102"], $this->respGood([$exp[1]])],
];
}
/** @dataProvider provideHeadlines */
public function testRetrieveHeadlines(bool $full, array $in, $out, Context $c, array $fields, array $order, ResponseInterface $exp): void {
$base = ['op' => $full ? "getHeadlines" : "getCompactHeadlines", 'sid' => "PriestsOfSyrinx"];
@ -1539,7 +1617,7 @@ LONG_STRING;
[true, ['feed_id' => -4, 'limit' => 5], $out, (clone $c)->limit(5), $fields, $sort, $expFull],
[true, ['feed_id' => -4, 'skip' => 2], $out, (clone $c)->offset(2), $fields, $sort, $expFull],
[true, ['feed_id' => -4, 'limit' => 5, 'skip' => 2], $out, (clone $c)->limit(5)->offset(2), $fields, $sort, $expFull],
[true, ['feed_id' => -4, 'since_id' => 47], $out, (clone $c)->oldestArticle(48), $fields, $sort, $expFull],
[true, ['feed_id' => -4, 'since_id' => 47], $out, (clone $c)->articleRange(48, null), $fields, $sort, $expFull],
[true, ['feed_id' => -3, 'is_cat' => true], $out, $c, $fields, $sort, $expFull],
[true, ['feed_id' => -4, 'is_cat' => true], $out, $c, $fields, $sort, $expFull],
[true, ['feed_id' => -2, 'is_cat' => true], $out, (clone $c)->labelled(true), $fields, $sort, $expFull],
@ -1551,10 +1629,10 @@ LONG_STRING;
[true, ['feed_id' => -4, 'order_by' => "feed_dates"], $out, $c, $fields, $sort, $expFull],
[true, ['feed_id' => -4, 'order_by' => "date_reverse"], $out, $c, $fields, ["edited_date"], $expFull],
[true, ['feed_id' => 42, 'search' => "interesting"], $out, (clone $c)->subscription(42)->searchTerms(["interesting"]), $fields, $sort, $expFull],
[true, ['feed_id' => -6], $out, (clone $c)->unread(false)->markedSince(Date::sub("PT24H", $t)), $fields, ["marked_date desc"], $expFull],
[true, ['feed_id' => -6], $out, (clone $c)->unread(false)->markedRange(Date::sub("PT24H", $t), null), $fields, ["marked_date desc"], $expFull],
[true, ['feed_id' => -6, 'view_mode' => "unread"], null, $c, $fields, $sort, $this->respGood([])],
[true, ['feed_id' => -3], $out, (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H", $t)), $fields, $sort, $expFull],
[true, ['feed_id' => -3, 'view_mode' => "marked"], $out, (clone $c)->unread(true)->starred(true)->modifiedSince(Date::sub("PT24H", $t)), $fields, $sort, $expFull],
[true, ['feed_id' => -3], $out, (clone $c)->unread(true)->modifiedRange(Date::sub("PT24H", $t), null), $fields, $sort, $expFull],
[true, ['feed_id' => -3, 'view_mode' => "marked"], $out, (clone $c)->unread(true)->starred(true)->modifiedRange(Date::sub("PT24H", $t), null), $fields, $sort, $expFull],
[false, [], null, (clone $c)->limit(null), [], [], $this->respErr("INCORRECT_USAGE")],
[false, ['feed_id' => 0], null, (clone $c)->limit(null), [], [], $this->respGood([])],
[false, ['feed_id' => -1], $comp, (clone $c)->limit(null)->starred(true), ["id"], ["marked_date desc"], $expComp],
@ -1571,11 +1649,11 @@ LONG_STRING;
[false, ['feed_id' => -4, 'limit' => 5], $comp, (clone $c)->limit(5), ["id"], $sort, $expComp],
[false, ['feed_id' => -4, 'skip' => 2], $comp, (clone $c)->limit(null)->offset(2), ["id"], $sort, $expComp],
[false, ['feed_id' => -4, 'limit' => 5, 'skip' => 2], $comp, (clone $c)->limit(5)->offset(2), ["id"], $sort, $expComp],
[false, ['feed_id' => -4, 'since_id' => 47], $comp, (clone $c)->limit(null)->oldestArticle(48), ["id"], $sort, $expComp],
[false, ['feed_id' => -6], $comp, (clone $c)->limit(null)->unread(false)->markedSince(Date::sub("PT24H", $t)), ["id"], ["marked_date desc"], $expComp],
[false, ['feed_id' => -4, 'since_id' => 47], $comp, (clone $c)->limit(null)->articleRange(48, null), ["id"], $sort, $expComp],
[false, ['feed_id' => -6], $comp, (clone $c)->limit(null)->unread(false)->markedRange(Date::sub("PT24H", $t), null), ["id"], ["marked_date desc"], $expComp],
[false, ['feed_id' => -6, 'view_mode' => "unread"], null, (clone $c)->limit(null), ["id"], $sort, $this->respGood([])],
[false, ['feed_id' => -3], $comp, (clone $c)->limit(null)->unread(true)->modifiedSince(Date::sub("PT24H", $t)), ["id"], $sort, $expComp],
[false, ['feed_id' => -3, 'view_mode' => "marked"], $comp, (clone $c)->limit(null)->unread(true)->starred(true)->modifiedSince(Date::sub("PT24H", $t)), ["id"], $sort, $expComp],
[false, ['feed_id' => -3], $comp, (clone $c)->limit(null)->unread(true)->modifiedRange(Date::sub("PT24H", $t), null), ["id"], $sort, $expComp],
[false, ['feed_id' => -3, 'view_mode' => "marked"], $comp, (clone $c)->limit(null)->unread(true)->starred(true)->modifiedRange(Date::sub("PT24H", $t), null), ["id"], $sort, $expComp],
];
}

View file

@ -101,25 +101,31 @@ class TestSearch extends \JKingWeb\Arsse\Test\AbstractTest {
'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")],
'Bare ISO date' => ['@2019-03-01', (new Context)->modifiedRanges([["2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z"]])],
'Quoted ISO date' => ['"@March 1st, 2019"', (new Context)->modifiedRanges([["2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z"]])],
'Bare negative ISO date' => ['-@2019-03-01', (new Context)->not->modifiedRanges([["2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z"]])],
'Quoted negative English date' => ['"-@March 1st, 2019"', (new Context)->not->modifiedRanges([["2019-03-01T00:00:00Z", "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")],
'Contradictory dates' => ['@2010-01-01 @2015-01-01', (new Context)->modifiedRanges([["2010-01-01T00:00:00Z", "2010-01-01T23:59:59Z"], ["2015-01-01T00:00:00Z", "2015-01-01T23:59:59Z"]])], // This differs from TTRSS' behaviour
'Doubled date' => ['"@March 1st, 2019" @2019-03-01', (new Context)->modifiedRanges([["2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z"]])],
'Doubled negative date' => ['"-@March 1st, 2019" -@2019-03-01', (new Context)->not->modifiedRanges([["2019-03-01T00:00:00Z", "2019-03-01T23:59:59Z"]])],
];
}
/** @dataProvider provideSearchStrings */
public function testApplySearchToContext(string $search, $exp): void {
$act = Search::parse($search);
$act = Search::parse($search, "UTC");
$this->assertEquals($exp, $act);
}
public function testApplySearchToContextWithTimeZone() {
$act = Search::parse("@2022-02-02", "America/Toronto");
$exp = (new Context)->modifiedRanges([["2022-02-02T05:00:00Z", "2022-02-03T04:59:59Z"]]);
$this->assertEquals($exp, $act);
}
}