Browse Source

Merge branch 'fever' into opml

microsub
J. King 2 years ago
parent
commit
2aa16f3405
  1. 2
      CHANGELOG
  2. 1
      README.md
  3. 6
      UPGRADING
  4. 2
      arsse.php
  5. 11
      composer.json
  6. 192
      composer.lock
  7. 5
      lib/Context/Context.php
  8. 69
      lib/Context/ExclusionContext.php
  9. 245
      lib/Database.php
  10. 277
      lib/REST/Fever/API.php
  11. 10
      lib/REST/NextCloudNews/V1_2.php
  12. 17
      lib/REST/TinyTinyRSS/API.php
  13. 4
      robo
  14. 64
      tests/cases/Database/SeriesArticle.php
  15. 25
      tests/cases/Misc/TestContext.php
  16. 341
      tests/cases/REST/Fever/TestAPI.php
  17. 34
      tests/cases/REST/NextCloudNews/TestV1_2.php
  18. 80
      tests/cases/REST/TinyTinyRSS/TestAPI.php
  19. 3
      tests/lib/AbstractTest.php
  20. 54
      vendor-bin/csfixer/composer.lock
  21. 24
      vendor-bin/phpunit/composer.lock
  22. 52
      vendor-bin/robo/composer.lock

2
CHANGELOG

@ -5,10 +5,12 @@ New features:
- Support for the Fever protocol (see README.md for details)
- Command line functionality for clearing a password, disabling the account
- Command line options for dealing with Fever passwords
- Command line functionality for exporting subscriptions to OPML
- Command line documentation of all commands and options
Bug fixes:
- Treat command line option -h the same as --help
- Sort Tiny Tiny RSS special feeds according to special ordering
Version 0.7.1 (2019-03-25)
==========================

1
README.md

@ -148,7 +148,6 @@ We are not aware of any other extensions to the TTRSS protocol. If you know of a
- Full-text search is not yet employed with any database, including PostgreSQL
- Article hashes are normally SHA1; The Arsse uses SHA256 hashes
- Article attachments normally have unique IDs; The Arsse always gives attachments an ID of `"0"`
- The default sort order of the `getHeadlines` operation normally uses custom sorting for "special" feeds; The Arsse's default sort order is equivalent to `feed_dates` for all feeds
- The `getCounters` operation normally omits members with zero unread; The Arsse includes everything to appease some clients
#### Other notes

6
UPGRADING

@ -10,6 +10,12 @@ usually prudent:
- If installing from source, update dependencies with:
`composer install -o --no-dev`
Upgrading from 0.7.1 to 0.8.0
=============================
- The database schema has changed from rev4 to rev5; if upgrading the database
manually, apply the 4.sql file
Upgrading from 0.5.1 to 0.6.0
=============================

2
arsse.php

@ -25,7 +25,7 @@ if (\PHP_SAPI === "cli") {
$conf = file_exists(BASE."config.php") ? new Conf(BASE."config.php") : new Conf;
Arsse::load($conf);
// handle Web requests
$emitter = new \Zend\Diactoros\Response\SapiEmitter();
$emitter = new \Zend\HttpHandlerRunner\Emitter\SapiEmitter;
$response = (new REST)->dispatch();
$emitter->emit($response);
}

11
composer.json

@ -18,16 +18,17 @@
],
"require": {
"php": "^7.0",
"php": "7.*",
"ext-intl": "*",
"ext-json": "*",
"ext-hash": "*",
"ext-dom": "*",
"p3k/picofeed": "0.1.*",
"hosteurope/password-generator": "^1.0",
"docopt/docopt": "^1.0",
"jkingweb/druuid": "^3.0",
"zendframework/zend-diactoros": "^1.6"
"hosteurope/password-generator": "1.*",
"docopt/docopt": "1.*",
"jkingweb/druuid": "3.*",
"zendframework/zend-diactoros": "2.*",
"zendframework/zend-httphandlerrunner": "1.*"
},
"require-dev": {
"bamarni/composer-bin-plugin": "*"

192
composer.lock

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f61a02cd168914d91847b89dcd00d464",
"content-hash": "c2b0698669d89268ffb995a5e1d6667a",
"packages": [
{
"name": "docopt/docopt",
@ -190,6 +190,58 @@
"homepage": "https://github.com/miniflux/picoFeed",
"time": "2017-11-30T00:16:58+00:00"
},
{
"name": "psr/http-factory",
"version": "1.0.1",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-factory.git",
"reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be",
"reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be",
"shasum": ""
},
"require": {
"php": ">=7.0.0",
"psr/http-message": "^1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "http://www.php-fig.org/"
}
],
"description": "Common interfaces for PSR-7 HTTP message factories",
"keywords": [
"factory",
"http",
"message",
"psr",
"psr-17",
"psr-7",
"request",
"response"
],
"time": "2019-04-30T12:38:16+00:00"
},
{
"name": "psr/http-message",
"version": "1.0.1",
@ -240,40 +292,96 @@
],
"time": "2016-08-06T14:39:51+00:00"
},
{
"name": "psr/http-server-handler",
"version": "1.0.1",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-server-handler.git",
"reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/aff2f80e33b7f026ec96bb42f63242dc50ffcae7",
"reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7",
"shasum": ""
},
"require": {
"php": ">=7.0",
"psr/http-message": "^1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Server\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "http://www.php-fig.org/"
}
],
"description": "Common interface for HTTP server-side request handler",
"keywords": [
"handler",
"http",
"http-interop",
"psr",
"psr-15",
"psr-7",
"request",
"response",
"server"
],
"time": "2018-10-30T16:46:14+00:00"
},
{
"name": "zendframework/zend-diactoros",
"version": "1.8.6",
"version": "2.1.3",
"source": {
"type": "git",
"url": "https://github.com/zendframework/zend-diactoros.git",
"reference": "20da13beba0dde8fb648be3cc19765732790f46e"
"reference": "279723778c40164bcf984a2df12ff2c6ec5e61c1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/20da13beba0dde8fb648be3cc19765732790f46e",
"reference": "20da13beba0dde8fb648be3cc19765732790f46e",
"url": "https://api.github.com/repos/zendframework/zend-diactoros/zipball/279723778c40164bcf984a2df12ff2c6ec5e61c1",
"reference": "279723778c40164bcf984a2df12ff2c6ec5e61c1",
"shasum": ""
},
"require": {
"php": "^5.6 || ^7.0",
"php": "^7.1",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.0"
},
"provide": {
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"ext-dom": "*",
"ext-libxml": "*",
"http-interop/http-factory-tests": "^0.5.0",
"php-http/psr7-integration-tests": "dev-master",
"phpunit/phpunit": "^5.7.16 || ^6.0.8 || ^7.2.7",
"zendframework/zend-coding-standard": "~1.0"
"phpunit/phpunit": "^7.0.2",
"zendframework/zend-coding-standard": "~1.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.8.x-dev",
"dev-develop": "1.9.x-dev",
"dev-release-2.0": "2.0.x-dev"
"dev-master": "2.1.x-dev",
"dev-develop": "2.2.x-dev",
"dev-release-1.8": "1.8.x-dev"
}
},
"autoload": {
@ -293,16 +401,70 @@
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
"BSD-3-Clause"
],
"description": "PSR HTTP Message implementations",
"homepage": "https://github.com/zendframework/zend-diactoros",
"keywords": [
"http",
"psr",
"psr-7"
],
"time": "2018-09-05T19:29:37+00:00"
"time": "2019-07-10T16:13:25+00:00"
},
{
"name": "zendframework/zend-httphandlerrunner",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/zendframework/zend-httphandlerrunner.git",
"reference": "75fb12751fe9d6e392cce1ee0d687dacae2db787"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/zendframework/zend-httphandlerrunner/zipball/75fb12751fe9d6e392cce1ee0d687dacae2db787",
"reference": "75fb12751fe9d6e392cce1ee0d687dacae2db787",
"shasum": ""
},
"require": {
"php": "^7.1",
"psr/http-message": "^1.0",
"psr/http-message-implementation": "^1.0",
"psr/http-server-handler": "^1.0"
},
"require-dev": {
"phpunit/phpunit": "^7.0.2",
"zendframework/zend-coding-standard": "~1.0.0",
"zendframework/zend-diactoros": "^1.7 || ^2.1.1"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.1.x-dev",
"dev-develop": "1.2.x-dev"
},
"zf": {
"config-provider": "Zend\\HttpHandlerRunner\\ConfigProvider"
}
},
"autoload": {
"psr-4": {
"Zend\\HttpHandlerRunner\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "Execute PSR-15 RequestHandlerInterface instances and emit responses they generate.",
"keywords": [
"ZendFramework",
"components",
"expressive",
"psr-15",
"psr-7",
"zf"
],
"time": "2019-02-19T18:20:34+00:00"
},
{
"name": "zendframework/zendxml",
@ -398,7 +560,7 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^7.0",
"php": "7.*",
"ext-intl": "*",
"ext-json": "*",
"ext-hash": "*",

5
lib/Context/Context.php

@ -9,7 +9,6 @@ namespace JKingWeb\Arsse\Context;
class Context extends ExclusionContext {
/** @var ExclusionContext */
public $not;
public $reverse = false;
public $limit = 0;
public $offset = 0;
public $unread;
@ -31,10 +30,6 @@ class Context extends ExclusionContext {
unset($this->not);
}
public function reverse(bool $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
public function limit(int $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}

69
lib/Context/ExclusionContext.php

@ -11,16 +11,23 @@ use JKingWeb\Arsse\Misc\Date;
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 $article;
public $editions;
public $article;
public $articles;
public $label;
public $labels;
public $labelName;
public $labelNames;
public $annotationTerms;
public $searchTerms;
public $titleTerms;
@ -42,6 +49,7 @@ class ExclusionContext {
}
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") {
@ -70,16 +78,18 @@ class ExclusionContext {
}
}
protected function cleanIdArray(array $spec): array {
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])) {
if (ValueInfo::id($spec[$a], $allowZero)) {
$spec[$a] = (int) $spec[$a];
} else {
$spec[$a] = 0;
$spec[$a] = null;
}
}
return array_values(array_unique(array_filter($spec)));
return array_values(array_unique(array_filter($spec, function($v) {
return !is_null($v);
})));
}
protected function cleanStringArray(array $spec): array {
@ -99,22 +109,57 @@ class ExclusionContext {
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);
}
@ -141,10 +186,24 @@ class ExclusionContext {
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);

245
lib/Database.php

@ -1239,6 +1239,37 @@ class Database {
)->run($feedID, $vId, $vHashUT, $vHashUC, $vHashTC);
}
/** Returns an associative array of result column names and their SQL computations for article queries
*
* This is used for whitelisting and defining both output column and order-by columns, as well as for resolution of some context options
*/
protected function articleColumns(): array {
$greatest = $this->db->sqlToken("greatest");
return [
'id' => "arsse_articles.id",
'edition' => "latest_editions.edition",
'url' => "arsse_articles.url",
'title' => "arsse_articles.title",
'author' => "arsse_articles.author",
'content' => "arsse_articles.content",
'guid' => "arsse_articles.guid",
'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash",
'folder' => "coalesce(arsse_subscriptions.folder,0)",
'subscription' => "arsse_subscriptions.id",
'feed' => "arsse_subscriptions.feed",
'starred' => "coalesce(arsse_marks.starred,0)",
'unread' => "abs(coalesce(arsse_marks.read,0) - 1)",
'note' => "coalesce(arsse_marks.note,'')",
'published_date' => "arsse_articles.published",
'edited_date' => "arsse_articles.edited",
'modified_date' => "arsse_articles.modified",
'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(label_stats.modified, '0001-01-01 00:00:00'))",
'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)",
'media_url' => "arsse_enclosures.url",
'media_type' => "arsse_enclosures.type",
];
}
/** Computes an SQL query to find and retrieve data about articles in the database
*
* If an empty column list is supplied, a count of articles matching the context is queried instead
@ -1271,48 +1302,30 @@ class Database {
$this->labelValidateId($user, $context->labelName, true);
}
// prepare the output column list; the column definitions are also used later
$greatest = $this->db->sqlToken("greatest");
$colDefs = [
'id' => "arsse_articles.id",
'edition' => "latest_editions.edition",
'url' => "arsse_articles.url",
'title' => "arsse_articles.title",
'author' => "arsse_articles.author",
'content' => "arsse_articles.content",
'guid' => "arsse_articles.guid",
'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash",
'folder' => "coalesce(arsse_subscriptions.folder,0)",
'subscription' => "arsse_subscriptions.id",
'feed' => "arsse_subscriptions.feed",
'starred' => "coalesce(arsse_marks.starred,0)",
'unread' => "abs(coalesce(arsse_marks.read,0) - 1)",
'note' => "coalesce(arsse_marks.note,'')",
'published_date' => "arsse_articles.published",
'edited_date' => "arsse_articles.edited",
'modified_date' => "arsse_articles.modified",
'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(label_stats.modified, '0001-01-01 00:00:00'))",
'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)",
'media_url' => "arsse_enclosures.url",
'media_type' => "arsse_enclosures.type",
];
$colDefs = $this->articleColumns();
if (!$cols) {
// if no columns are specified return a count
$columns = "count(distinct arsse_articles.id) as count";
// if no columns are specified return a count; don't borther with sorting
$outColumns = "count(distinct arsse_articles.id) as count";
} else {
$columns = [];
// normalize requested output and sorting columns
$norm = function($v) {
return trim(strtolower(ValueInfo::normalize($v, ValueInfo::T_STRING)));
};
$cols = array_map($norm, $cols);
// make an output column list
$outColumns = [];
foreach ($cols as $col) {
$col = trim(strtolower($col));
if (!isset($colDefs[$col])) {
continue;
}
$columns[] = $colDefs[$col]." as ".$col;
$outColumns[] = $colDefs[$col]." as ".$col;
}
$columns = implode(",", $columns);
$outColumns = implode(",", $outColumns);
}
// define the basic query, to which we add lots of stuff where necessary
$q = new Query(
"SELECT
$columns
$outColumns
from arsse_articles
join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed and arsse_subscriptions.owner = ?
join arsse_feeds on arsse_subscriptions.feed = arsse_feeds.id
@ -1344,7 +1357,9 @@ class Database {
"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", ""],
];
@ -1395,6 +1410,79 @@ class Database {
$q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m);
}
}
// handle 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],
],
],
];
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];
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) {
list($test, $types, $values) = $this->generateIn($context->$m, $named ? "str" : "int");
$test = "in ($test)";
} else {
$test = "= ?";
$types = $named ? "str" : "int";
$values = $context->$m;
}
$q->setWhere("$match in (select $selection from $table where $col $test)", $types, $values);
}
if ($context->not->$m()) {
$seen = true;
if ($multi) {
list($test, $types, $values) = $this->generateIn($context->not->$m, $named ? "str" : "int");
$test = "in ($test)";
} else {
$test = "= ?";
$types = $named ? "str" : "int";
$values = $context->not->$m;
}
$q->setWhereNot("$match in (select $selection from $table where $col $test)", $types, $values);
}
}
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) ? "<>" : "=";
@ -1405,48 +1493,32 @@ class Database {
$op = $context->labelled ? ">" : "=";
$q->setWhere("coalesce(label_stats.assigned,0) $op 0");
}
if ($context->label() || $context->not->label() || $context->labelName() || $context->not->labelName()) {
$q->setCTE("labelled(article,label_id,label_name)", "SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1", "str", $user);
if ($context->label()) {
$q->setWhere("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->label);
}
if ($context->not->label()) {
$q->setWhereNot("arsse_articles.id in (select article from labelled where label_id = ?)", "int", $context->not->label);
}
if ($context->labelName()) {
$q->setWhere("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->labelName);
}
if ($context->not->labelName()) {
$q->setWhereNot("arsse_articles.id in (select article from labelled where label_name = ?)", "str", $context->not->labelName);
}
}
if ($context->tag() || $context->not->tag() || $context->tagName() || $context->not->tagName()) {
$q->setCTE("tagged(id,name,subscription)", "SELECT arsse_tags.id, arsse_tags.name, arsse_tag_members.subscription FROM arsse_tag_members join arsse_tags on arsse_tags.id = arsse_tag_members.tag WHERE arsse_tags.owner = ? and assigned = 1", "str", $user);
if ($context->tag()) {
$q->setWhere("arsse_subscriptions.id in (select subscription from tagged where id = ?)", "int", $context->tag);
}
if ($context->not->tag()) {
$q->setWhereNot("arsse_subscriptions.id in (select subscription from tagged where id = ?)", "int", $context->not->tag);
}
if ($context->tagName()) {
$q->setWhere("arsse_subscriptions.id in (select subscription from tagged where name = ?)", "str", $context->tagName);
}
if ($context->not->tagName()) {
$q->setWhereNot("arsse_subscriptions.id in (select subscription from tagged where name = ?)", "str", $context->not->tagName);
}
}
if ($context->folder()) {
// add a common table expression to list the folder and its children so that we select from the entire subtree
$q->setCTE("folders(folder)", "SELECT ? union select id from arsse_folders join folders on parent = folder", "int", $context->folder);
$q->setCTE("folders(folder)", "SELECT ? union 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()) {
list($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 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 select id from arsse_folders join folders_excluded on parent = folder", "int", $context->not->folder);
$q->setCTE("folders_excluded(folder)", "SELECT ? union 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()) {
list($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 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 = [
"titleTerms" => ["arsse_articles.title"],
@ -1454,20 +1526,20 @@ class Database {
"authorTerms" => ["arsse_articles.author"],
"annotationTerms" => ["arsse_marks.note"],
];
foreach ($options as $m => $cols) {
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
}
$q->setWhere(...$this->generateSearch($context->$m, $cols));
$q->setWhere(...$this->generateSearch($context->$m, $columns));
}
// further handle exclusionary text-matching context options
foreach ($options as $m => $cols) {
foreach ($options as $m => $columns) {
if (!$context->not->$m() || !$context->not->$m) {
continue;
}
$q->setWhereNot(...$this->generateSearch($context->not->$m, $cols, true));
$q->setWhereNot(...$this->generateSearch($context->not->$m, $columns, true));
}
// return the query
return $q;
@ -1479,16 +1551,47 @@ class Database {
*
* @param string $user The user whose articles are to be listed
* @param Context $context The search context
* @param array $cols The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type
* @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"]): Db\Result {
public function articleList(string $user, Context $context = null, array $fields = ["id"], array $sort = []): Db\Result {
if (!Arsse::$user->authorize($user, __FUNCTION__)) {
throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
}
// make a base query based on context and output columns
$context = $context ?? new Context;
$q = $this->articleQuery($user, $context, $fields);
$q->setOrder("arsse_articles.edited".($context->reverse ? " desc" : ""));
$q->setOrder("latest_editions.edition".($context->reverse ? " desc" : ""));
// make an ORDER BY column list
$colDefs = $this->articleColumns();
// normalize requested output and sorting columns
$norm = function($v) {
return trim(strtolower((string) $v));
};
$fields = array_map($norm, $fields);
$sort = array_map($norm, $sort);
foreach ($sort as $spec) {
$col = explode(" ", $spec, 2);
$order = $col[1] ?? "";
$col = $col[0];
if ($order === "desc") {
$order = " desc";
} elseif ($order === "asc" || $order === "") {
$order = "";
} else {
// column direction spec is bogus
continue;
}
if (!isset($colDefs[$col])) {
// column name spec is bogus
continue;
} elseif (in_array($col, $fields)) {
// if the sort column is also an output column, use it as-is
$q->setOrder($col.$order);
} else {
// otherwise if the column name is valid, use its expression
$q->setOrder($colDefs[$col].$order);
}
}
// perform the query and return results
return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
}

277
lib/REST/Fever/API.php

@ -11,7 +11,7 @@ use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Service;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\Misc\ValueInfo as V;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\Db\ExceptionInput;
@ -21,26 +21,54 @@ use JKingWeb\Arsse\REST\Exception405;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\Response\JsonResponse;
use Zend\Diactoros\Response\XmlResponse;
use Zend\Diactoros\Response\EmptyResponse;
class API extends \JKingWeb\Arsse\REST\AbstractHandler {
const LEVEL = 3;
const GENERIC_ICON_TYPE = "image/png;base64";
const GENERIC_ICON_DATA = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAADUlEQVQYV2NgYGBgAAAABQABijPjAAAAAABJRU5ErkJggg==";
// GET parameters for which we only check presence: these will be converted to booleans
const PARAM_BOOL = ["groups", "feeds", "items", "favicons", "links", "unread_item_ids", "saved_item_ids"];
// GET parameters which contain meaningful values
const PARAM_GET = [
'api' => V::T_STRING, // this parameter requires special handling
'page' => V::T_INT, // parameter for hot links
'range' => V::T_INT, // parameter for hot links
'offset' => V::T_INT, // parameter for hot links
'since_id' => V::T_INT,
'max_id' => V::T_INT,
'with_ids' => V::T_STRING,
'group_ids' => V::T_STRING, // undocumented parameter for 'items' lookup
'feed_ids' => V::T_STRING, // undocumented parameter for 'items' lookup
];
// POST parameters, all of which contain meaningful values
const PARAM_POST = [
'api_key' => V::T_STRING,
'mark' => V::T_STRING,
'as' => V::T_STRING,
'id' => V::T_INT,
'before' => V::T_DATE,
'unread_recently_read' => V::T_BOOL,
];
public function __construct() {
}
public function dispatch(ServerRequestInterface $req): ResponseInterface {
$inR = $req->getQueryParams() ?? [];
$inW = $req->getParsedBody() ?? [];
if (!array_key_exists("api", $inR)) {
$G = $this->normalizeInputGet($req->getQueryParams() ?? []);
$P = $this->normalizeInputPost($req->getParsedBody() ?? []);
if (!isset($G['api'])) {
// the original would have shown the Fever UI in the absence of the "api" parameter, but we'll return 404
return new EmptyResponse(404);
}
$xml = $inR['api'] === "xml";
switch ($req->getMethod()) {
case "OPTIONS":
// do stuff
break;
return new EmptyResponse(204, [
'Allow' => "POST",
'Accept' => "application/x-www-form-urlencoded",
]);
case "POST":
if (strlen($req->getHeaderLine("Content-Type")) && $req->getHeaderLine("Content-Type") !== "application/x-www-form-urlencoded") {
return new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded"]);
@ -58,31 +86,87 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(401);
}
// produce a full response if authenticated or a basic response otherwise
if ($this->logIn(strtolower($inW['api_key'] ?? ""))) {
$out = $this->processRequest($this->baseResponse(true), $inR, $inW);
if ($this->logIn(strtolower($P['api_key'] ?? ""))) {
$out = $this->processRequest($this->baseResponse(true), $G, $P);
} else {
$out = $this->baseResponse(false);
}
// return the result, possibly formatted as XML
return $this->formatResponse($out, $xml);
break;
return $this->formatResponse($out, ($G['api'] === "xml"));
default:
return new EmptyResponse(405, ['Allow' => "OPTIONS,POST"]);
}
}
protected function normalizeInputGet(array $data): array {
$out = [];
if (array_key_exists("api", $data)) {
// the "api" parameter must be handled specially as it a string, but null has special meaning
$data['api'] = $data['api'] ?? "json";
}
foreach (self::PARAM_BOOL as $p) {
// first handle all the boolean parameters
$out[$p] = array_key_exists($p, $data);
}
foreach (self::PARAM_GET as $p => $t) {
$out[$p] = V::normalize($data[$p] ?? null, $t | V::M_DROP, "unix");
}
return $out;
}
protected function normalizeInputPost(array $data): array {
$out = [];
foreach (self::PARAM_POST as $p => $t) {
$out[$p] = V::normalize($data[$p] ?? null, $t | V::M_DROP, "unix");
}
return $out;
}
protected function processRequest(array $out, array $G, array $P): array {
if (array_key_exists("feeds", $G) || array_key_exists("groups", $G)) {
if (array_key_exists("groups", $G)) {
$listUnread = false;
$listSaved = false;
if ($P['unread_recently_read']) {
$this->setUnread();
$listUnread = true;
}
if ($P['mark'] && $P['as'] && is_int($P['id'])) {
// depending on which mark are being made,
// either an 'unread_item_ids' or a
// 'saved_item_ids' entry will be added later
$listSaved = $this->setMarks($P, $listUnread);
}
if ($G['feeds'] || $G['groups']) {
if ($G['groups']) {
$out['groups'] = $this->getGroups();
}
if (array_key_exists("feeds", $G)) {
if ($G['feeds']) {
$out['feeds'] = $this->getFeeds();
}
$out['feeds_groups'] = $this->getRelationships();
}
if (array_key_exists("favicons", $G)) {
# deal with favicons
if ($G['favicons']) {
// TODO: implement favicons properly
// we provide a single blank favicon for now
$out['favicons'] = [
[
'id' => 0,
'data' => self::GENERIC_ICON_TYPE.",".self::GENERIC_ICON_DATA,
],
];
}
if ($G['items']) {
$out['items'] = $this->getItems($G);
$out['total_items'] = Arsse::$db->articleCount(Arsse::$user->id);
}
if ($G['links']) {
// TODO: implement hot links
$out['links'] = [];
}
if ($G['unread_item_ids'] || $listUnread) {
$out['unread_item_ids'] = $this->getItemIds((new Context)->unread(true));
}
if ($G['saved_item_ids'] || $listSaved) {
$out['saved_item_ids'] = $this->getItemIds((new Context)->starred(true));
}
return $out;
}
@ -101,12 +185,49 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
protected function formatResponse(array $data, bool $xml): ResponseInterface {
if ($xml) {
throw \Exception("Not implemented yet");
$d = new \DOMDocument("1.0", "utf-8");
$d->appendChild($this->makeXMLAssoc($data, $d->createElement("response")));
return new XmlResponse($d->saveXML());
} else {
return new JsonResponse($data, 200, [], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
}
}
protected function makeXMLAssoc(array $data, \DOMElement $p): \DOMElement {
$d = $p->ownerDocument;
foreach ($data as $k => $v) {
if (!is_array($v)) {
$p->appendChild($d->createElement($k, (string) $v));
} elseif (isset($v[0])) {
// this is a very simplistic check for an indexed array
// it would not pass muster in the face of generic data,
// but we'll assume our code produces only well-ordered
// indexed arrays
$p->appendChild($this->makeXMLIndexed($v, $d->createElement($k), substr($k, 0, strlen($k) - 1)));
} else {
$p->appendChild($this->makeXMLAssoc($v, $d->createElement($k)));
}
}
return $p;
}
protected function makeXMLIndexed(array $data, \DOMElement $p, string $k): \DOMElement {
$d = $p->ownerDocument;
foreach ($data as $v) {
if (!is_array($v)) {
// this case is never encountered with Fever's output
$p->appendChild($d->createElement($k, (string) $v)); // @codeCoverageIgnore
} elseif (isset($v[0])) {
// this case is never encountered with Fever's output
$p->appendChild($this->makeXMLIndexed($v, $d->createElement($k), substr($k, 0, strlen($k) - 1))); // @codeCoverageIgnore
} else {
$p->appendChild($this->makeXMLAssoc($v, $d->createElement($k)));
}
}
return $p;
}
protected function logIn(string $hash): bool {
// if HTTP authentication was successful and sessions are not enforced, proceed unconditionally
if (isset(Arsse::$user->id) && !Arsse::$conf->userSessionEnforced) {
@ -123,6 +244,80 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
return true;
}
protected function setMarks(array $P, &$listUnread): bool {
$listSaved = false;
$c = new Context;
$id = $P['id'];
if ($P['before']) {
$c->notMarkedSince($P['before']);
}
switch ($P['mark']) {
case "item":
$c->article($id);
break;
case "group":
if ($id > 0) {
// concrete groups
$c->tag($id);
} elseif ($id < 0) {
// group negative-one is the "Sparks" supergroup i.e. no feeds
$c->not->folder(0);
} else {
// group zero is the "Kindling" supergroup i.e. all feeds
// nothing need to be done for this
}
break;
case "feed":
$c->subscription($id);
break;
default:
return $listSaved;
}
switch ($P['as']) {
case "read":
$data = ['read' => true];
$listUnread = true;
break;
case "unread":
// this option is undocumented, but valid
$data = ['read' => false];
$listUnread = true;
break;
case "saved":
$data = ['starred' => true];
$listSaved = true;
break;
case "unsaved":
$data = ['starred' => false];
$listSaved = true;
break;
default:
return $listSaved;
}
try {
Arsse::$db->articleMark(Arsse::$user->id, $data, $c);
} catch (ExceptionInput $e) {
// ignore any errors
}
return $listSaved;
}
protected function setUnread() {
$lastUnread = Arsse::$db->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->getValue();
if (!$lastUnread) {
// there are no articles
return;
}
// Fever takes the date of the last read article less fifteen seconds as a cut-off.
// We take the date of last mark (whether it be read, unread, saved, unsaved), which
// may not actually signify a mark, but we'll otherwise also count back fifteen seconds
$c = new Context;
$lastUnread = Date::normalize($lastUnread, "sql");
$since = Date::sub("PT15S", $lastUnread);
$c->unread(false)->markedSince($since);
Arsse::$db->articleMark(Arsse::$user->id, ['read' => false], $c);
}
protected function getRefreshTime() {
return Date::transform(Arsse::$db->subscriptionRefreshed(Arsse::$user->id), "unix");
}
@ -132,7 +327,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
foreach (arsse::$db->subscriptionList(Arsse::$user->id) as $sub) {
$out[] = [
'id' => (int) $sub['id'],
'favicon_id' => (int) ($sub['favicon'] ? $sub['feed'] : 0),
'favicon_id' => 0, // TODO: implement favicons
'title' => (string) $sub['title'],
'url' => $sub['url'],
'site_url' => $sub['source'],
@ -171,4 +366,50 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
return $out;
}
protected function getItems(array $G): array {
$c = (new Context)->limit(50);
$reverse = false;
// handle the standard options
if ($G['with_ids']) {
$c->articles(explode(",", $G['with_ids']));
} elseif ($G['max_id']) {
$c->latestArticle($G['max_id'] - 1);
$reverse = true;
} elseif ($G['since_id']) {
$c->oldestArticle($G['since_id'] + 1);
}
// handle the undocumented options
if ($G['group_ids']) {
$c->tags(explode(",", $G['group_ids']));
}
if ($G['feed_ids']) {
$c->subscriptions(explode(",", $G['feed_ids']));
}
// get results
$out = [];
$order = $reverse ? "id desc" : "id";
foreach (Arsse::$db->articleList(Arsse::$user->id, $c, ["id", "subscription", "title", "author", "content", "url", "starred", "unread", "published_date"], [$order]) as $r) {
$out[] = [
'id' => (int) $r['id'],
'feed_id' => (int) $r['subscription'],
'title' => (string) $r['title'],
'author' => (string) $r['author'],
'html' => (string) $r['content'],
'url' => (string) $r['url'],
'is_saved' => (int) $r['starred'],
'is_read' => (int) !$r['unread'],
'created_on_time' => Date::transform($r['published_date'], "unix", "sql"),
];
}
return $out;
}
protected function getItemIds(Context $c = null): string {
$out = [];
foreach (Arsse::$db->articleList(Arsse::$user->id, $c) as $r) {
$out[] = (int) $r['id'];
}
return implode(",", $out);
}
}

10
lib/REST/NextCloudNews/V1_2.php

@ -521,14 +521,10 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
$c->limit($data['batchSize']);
}
// set the order of returned items
if ($data['oldestFirst']) {
$c->reverse(false);
} else {
$c->reverse(true);
}
$reverse = !$data['oldestFirst'];
// 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 ($c->reverse) {
if ($reverse) {
$c->latestEdition($data['offset'] - 1);
} else {
$c->oldestEdition($data['offset'] + 1);
@ -579,7 +575,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
"starred",
"modified_date",
"fingerprint",
]);
], [$reverse ? "edition desc" : "edition"]);
} catch (ExceptionInput $e) {
// ID of subscription or folder is not valid
return new EmptyResponse(422);

17
lib/REST/TinyTinyRSS/API.php

@ -8,11 +8,10 @@ namespace JKingWeb\Arsse\REST\TinyTinyRSS;
use JKingWeb\Arsse\Feed;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Service;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\ExceptionType;
@ -1439,7 +1438,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// no context needed here
break;
case self::FEED_READ:
$c->markedSince(Date::sub("PT24H"))->unread(false); // FIXME: this selects any recently touched article which is read, not necessarily a recently read one
$c->markedSince(Date::sub("PT24H"))->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
@ -1492,15 +1491,15 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
switch ($data['order_by']) {
case "date_reverse":
// sort oldest first
$c->reverse(false);
$order = ["edited_date"];
break;
case "feed_dates":
// sort newest first
$c->reverse(true);
$order = ["edited_date desc"];
break;
default:
// in TT-RSS the default sort order is unusual for some of the special feeds; we do not implement this
$c->reverse(true);
// sort most recently marked for special feeds, newest first otherwise
$order = (!$cat && ($id == self::FEED_READ || $id == self::FEED_STARRED)) ? ["marked_date desc"] : ["edited_date desc"];
break;
}
// set the limit and offset
@ -1515,6 +1514,6 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
$c->oldestArticle($data['since_id'] + 1);
}
// return results
return Arsse::$db->articleList(Arsse::$user->id, $c, $fields);
return Arsse::$db->articleList(Arsse::$user->id, $c, $fields, $order);
}
}

4
robo

@ -5,7 +5,7 @@ shift
ulimit -n 2048
if [ "$1" = "clean" ]; then
"$base/vendor/bin/robo" "$roboCommand" $*
"$base/vendor/bin/robo" "$roboCommand" "$@"
else
"$base/vendor/bin/robo" "$roboCommand" -- $*
"$base/vendor/bin/robo" "$roboCommand" -- "$@"
fi

64
tests/cases/Database/SeriesArticle.php

@ -10,6 +10,7 @@ use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\ValueInfo;
use Phake;
trait SeriesArticle {
@ -424,10 +425,15 @@ trait SeriesArticle {
return [
'Blank context' => [new Context, [1,2,3,4,5,6,7,8,19,20]],
'Folder tree' => [(new Context)->folder(1), [5,6,7,8]],
'Entire folder tree' => [(new Context)->folder(0), [1,2,3,4,5,6,7,8,19,20]],
'Leaf folder' => [(new Context)->folder(6), [7,8]],
'Root folder only' => [(new Context)->folderShallow(0), [1,2,3,4]],
'Multiple folder trees' => [(new Context)->folders([1,5]), [5,6,7,8,19,20]],
'Multiple folder trees including root' => [(new Context)->folders([0,1,5]), [1,2,3,4,5,6,7,8,19,20]],
'Shallow folder' => [(new Context)->folderShallow(1), [5,6]],
'Root folder only' => [(new Context)->folderShallow(0), [1,2,3,4]],
'Multiple shallow folders' => [(new Context)->foldersShallow([1,6]), [5,6,7,8]],
'Subscription' => [(new Context)->subscription(5), [19,20]],
'Multiple subscriptions' => [(new Context)->subscriptions([4,5]), [7,8,19,20]],
'Unread' => [(new Context)->subscription(5)->unread(true), [20]],
'Read' => [(new Context)->subscription(5)->unread(false), [19]],
'Starred' => [(new Context)->starred(true), [1,20]],
@ -455,11 +461,12 @@ trait SeriesArticle {
'Marked or labelled between 2000 and 2015' => [(new Context)->markedSince("2000-01-01T00:00:00Z")->notMarkedSince("2015-12-31T23:59:59Z"), [1,2,3,4,5,6,7,8,20]],
'Marked or labelled in 2010' => [(new Context)->markedSince("2010-01-01T00:00:00Z")->notMarkedSince("2010-12-31T23:59:59Z"), [2,4,6,20]],
'Paged results' => [(new Context)->limit(2)->oldestEdition(4), [4,5]],
'Reversed paged results' => [(new Context)->limit(2)->latestEdition(7)->reverse(true), [7,6]],
'With label ID 1' => [(new Context)->label(1), [1,19]],
'With label ID 2' => [(new Context)->label(2), [1,5,20]],
'With label ID 1 or 2' => [(new Context)->labels([1,2]), [1,5,19,20]],
'With label "Interesting"' => [(new Context)->labelName("Interesting"), [1,19]],
'With label "Fascinating"' => [(new Context)->labelName("Fascinating"), [1,5,20]],
'With label "Interesting" or "Fascinating"' => [(new Context)->labelNames(["Interesting","Fascinating"]), [1,5,19,20]],
'Article ID 20' => [(new Context)->article(20), [20]],
'Edition ID 1001' => [(new Context)->edition(1001), [20]],
'Multiple articles' => [(new Context)->articles([1,20,50]), [1,20]],
@ -494,12 +501,19 @@ trait SeriesArticle {
'Search 501 terms' => [(new Context)->searchTerms(array_merge(range(1, 500), [str_repeat("a", 1000)])), []],
'With tag ID 1' => [(new Context)->tag(1), [5,6,7,8]],
'With tag ID 5' => [(new Context)->tag(5), [7,8,19,20]],
'With tag ID 1 or 5' => [(new Context)->tags([1,5]), [5,6,7,8,19,20]],
'With tag "Technology"' => [(new Context)->tagName("Technology"), [5,6,7,8]],
'With tag "Politics"' => [(new Context)->tagName("Politics"), [7,8,19,20]],
'With tag "Technology" or "Politics"' => [(new Context)->tagNames(["Technology","Politics"]), [5,6,7,8,19,20]],
'Excluding tag ID 1' => [(new Context)->not->tag(1), [1,2,3,4,19,20]],
'Excluding tag ID 5' => [(new Context)->not->tag(5), [1,2,3,4,5,6]],
'Excluding tag "Technology"' => [(new Context)->not->tagName("Technology"), [1,2,3,4,19,20]],
'Excluding tag "Politics"' => [(new Context)->not->tagName("Politics"), [1,2,3,4,5,6]],
'Excluding tags ID 1 and 5' => [(new Context)->not->tags([1,5]), [1,2,3,4]],
'Excluding tags "Technology" and "Politics"' => [(new Context)->not->tagNames(["Technology","Politics"]), [1,2,3,4]],
'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]), []],
];
}
@ -563,6 +577,25 @@ trait SeriesArticle {
$this->assertEquals($this->fields, $test);
}
/** @dataProvider provideOrderedLists */
public function testListArticlesCheckingOrder(array $sortCols, array $exp) {
$act = ValueInfo::normalize(array_column(iterator_to_array(Arsse::$db->articleList("john.doe@example.com", null, ["id"], $sortCols)), "id"), ValueInfo::T_INT | ValueInfo::M_ARRAY);
$this->assertSame($exp, $act);
}
public function provideOrderedLists() {
return [
[["id"], [1,2,3,4,5,6,7,8,19,20]],
[["id asc"], [1,2,3,4,5,6,7,8,19,20]],
[["id desc"], [20,19,8,7,6,5,4,3,2,1]],
[["edition"], [1,2,3,4,5,6,7,8,19,20]],
[["edition asc"], [1,2,3,4,5,6,7,8,19,20]],
[["edition desc"], [20,19,8,7,6,5,4,3,2,1]],
[["id", "edition desk"], [1,2,3,4,5,6,7,8,19,20]],
[["id", "editio"], [1,2,3,4,5,6,7,8,19,20]],
];
}
public function testListArticlesWithoutAuthority() {
Phake::when(Arsse::$user)->authorize->thenReturn(false);
$this->assertException("notAuthorized", "User", "ExceptionAuthz");
@ -783,11 +816,6 @@ trait SeriesArticle {
$this->compareExpectations(static::$drv, $state);
}
public function testMarkTooFewMultipleArticles() {
$this->assertException("tooShort", "Db", "ExceptionInput");
Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles([]));
}
public function testMarkTooManyMultipleArticles() {
$this->assertSame(7, Arsse::$db->articleMark($this->user, ['read'=>false,'starred'=>true], (new Context)->articles(range(1, Database::LIMIT_SET_SIZE * 3))));
}
@ -854,11 +882,6 @@ trait SeriesArticle {
$this->compareExpectations(static::$drv, $state);
}
public function testMarkTooFewMultipleEditions() {
$this->assertException("tooShort", "Db", "ExceptionInput");