Compare commits

...

58 Commits

Author SHA1 Message Date
J. King a25e777ec6 Version bump 3 weeks ago
J. King 44e2c9c13e Update documentation 3 weeks ago
J. King 866800dcc5 Finish last Guzzle-related tests 3 weeks ago
J. King 136d3782e3 Update changelog 2 months ago
J. King 3be3f43bab Start on tests for response wrappers 2 months ago
J. King d2f3f19128 Fix failures 2 months ago
J. King 459e44e041 Address remaining errors 2 months ago
J. King 56f015bfb9 More Guzzle conversion 2 months ago
J. King 64ec3f6ae4 Use unused variable 2 months ago
J. King 4d18bf27e2 Adjust most uses of Diactoros to Guzzle PSR-7 2 months ago
J. King e588a52e88 Replace ServerRequestFactory 2 months ago
J. King 6c0183faea Replace instances of Diactoros' EmptyResponse 2 months ago
J. King 560d4db139 Remove Diactoros in favour of Guzzle PSR-7 2 months ago
J. King 2557c22410 Update dependencies 4 months ago
J. King 4ca7b65a65 Update dependencies 4 months ago
J. King 4d37ae30ae Update dependencies 4 months ago
J. King d1da6fbe5e Use cases rather than casting bools to int in SQL 4 months ago
J. King d54733ad98 Update link to Nextcloud News documentation again 5 months ago
J. King a0c31fac5d Merge branch 'reader' 5 months ago
J. King 59358ec35b More PHP 7 fixes 5 months ago
J. King 90b66241b3 Fixes for PHP 7 5 months ago
J. King 761b3d5333 Return removed articles correctly in Miniflux 5 months ago
J. King d64dc751f9 Tests for query filters 5 months ago
J. King f51acb4264 Build exceptions correctly in Miniflux for clarity 5 months ago
J. King 300225439c Fix trivial error in Miniflux 5 months ago
J. King c6cc2a1a42 Restore coverage for Query class 5 months ago
J. King a44fe103d8 Prototype for nesting query filters 5 months ago
J. King 630536d789 Tests for union context 5 months ago
J. King 206c5c0012 Fill in union context 5 months ago
J. King 0c8f33c37c Remove setCTE and pushCTE from query builder 5 months ago
J. King 26e431b1a5 Simplify more queries 5 months ago
J. King 336207741d Add missing API documentation 5 months ago
J. King 6863c182d7 Update reference to the "Reeder" client 5 months ago
J. King f2aad7188c Update links to TT-RSS documentation 5 months ago
J. King 65b1bb4fcd Allow multiple dates in TT-RSS searches 5 months ago
J. King 2c5b9a6768 Fix missing TTRSS coverage 5 months ago
J. King 17832ac63e Allow timezone in TT-RSS search queries 5 months ago
J. King e65069885b Clean up obsolete FIXMEs 5 months ago
J. King 7e5d8494c4 Tests for selecting arrays of ranges 5 months ago
J. King e6505a5fda Work around possible MySQL bug 5 months ago
J. King 2acacd2647 Implement handling for arrays of ranges 6 months ago
J. King f6799e2ab1 Tests for date ranges in contexts 6 months ago
J. King 33a3478a58 Avoid use of PHP 7.4 feature 6 months ago
J. King 2489743d0f Further simplifications 6 months ago
J. King 0bd01849bb Remove unnecessary in() clause 6 months ago
J. King 895c045c9b Simplify folder selection in article queries 6 months ago
J. King fe02613214 Fix coverage 6 months ago
J. King 427bddd3b7 Allow multiple date ranges 6 months ago
J. King 53ba591720 Finish up article selection refactor 6 months ago
J. King 97dfef3267 Fix typos 6 months ago
J. King 396ca86482 Start on removal of conditional CTEs 6 months ago
J. King 4a87926dd5 Fix up context tests 6 months ago
J. King 6f1332c559 Start to shore up testing 6 months ago
J. King 308b592b18 Clean up coontext classes 6 months ago
J. King 983fa58ec8 Convert article and edition ranges to atomic 6 months ago
J. King 2c2bb4a856 Retrofits dates to use ranges 6 months ago
J. King c993168002 Update URL of Nextcloud News documentation 6 months ago
J. King 73497688fc Break contexts up into traits 6 months ago
  1. 10
      CHANGELOG
  2. 7
      UPGRADING
  3. 2
      composer.json
  4. 274
      composer.lock
  5. 1
      docs/en/030_Supported_Protocols/005_Miniflux.md
  6. 2
      docs/en/030_Supported_Protocols/010_Nextcloud_News.md
  7. 4
      docs/en/030_Supported_Protocols/020_Tiny_Tiny_RSS.md
  8. 4
      docs/en/040_Compatible_Clients.md
  9. 2
      lib/Arsse.php
  10. 27
      lib/Context/AbstractContext.php
  11. 35
      lib/Context/BooleanMembers.php
  12. 40
      lib/Context/Context.php
  13. 250
      lib/Context/ExclusionContext.php
  14. 262
      lib/Context/ExclusionMembers.php
  15. 20
      lib/Context/RootContext.php
  16. 50
      lib/Context/UnionContext.php
  17. 610
      lib/Database.php
  18. 4
      lib/Db/MySQL/Driver.php
  19. 2
      lib/Db/MySQL/Statement.php
  20. 2
      lib/Db/PDOStatement.php
  21. 2
      lib/Db/PostgreSQL/Statement.php
  22. 2
      lib/Db/SQLite3/Driver.php
  23. 2
      lib/Db/SQLite3/Statement.php
  24. 27
      lib/Misc/HTTP.php
  25. 75
      lib/Misc/Query.php
  26. 75
      lib/Misc/QueryFilter.php
  27. 6
      lib/Misc/ValueInfo.php
  28. 10
      lib/REST.php
  29. 25
      lib/REST/Fever/API.php
  30. 19
      lib/REST/Miniflux/ErrorResponse.php
  31. 11
      lib/REST/Miniflux/Status.php
  32. 245
      lib/REST/Miniflux/V1.php
  33. 146
      lib/REST/NextcloudNews/V1_2.php
  34. 11
      lib/REST/NextcloudNews/Versions.php
  35. 44
      lib/REST/TinyTinyRSS/API.php
  36. 14
      lib/REST/TinyTinyRSS/Icon.php
  37. 42
      lib/REST/TinyTinyRSS/Search.php
  38. 2
      locale/en.php
  39. 2
      sql/SQLite3/0.sql
  40. 2
      sql/SQLite3/2.sql
  41. 64
      tests/cases/Database/SeriesArticle.php
  42. 4
      tests/cases/Db/BaseDriver.php
  43. 182
      tests/cases/Misc/TestContext.php
  44. 27
      tests/cases/Misc/TestHTTP.php
  45. 61
      tests/cases/Misc/TestQuery.php
  46. 172
      tests/cases/Misc/TestValueInfo.php
  47. 62
      tests/cases/REST/Fever/TestAPI.php
  48. 22
      tests/cases/REST/Miniflux/TestErrorResponse.php
  49. 17
      tests/cases/REST/Miniflux/TestStatus.php
  50. 465
      tests/cases/REST/Miniflux/TestV1.php
  51. 211
      tests/cases/REST/NextcloudNews/TestV1_2.php
  52. 11
      tests/cases/REST/NextcloudNews/TestVersions.php
  53. 69
      tests/cases/REST/TestREST.php
  54. 261
      tests/cases/REST/TinyTinyRSS/TestAPI.php
  55. 24
      tests/cases/REST/TinyTinyRSS/TestIcon.php
  56. 22
      tests/cases/REST/TinyTinyRSS/TestSearch.php
  57. 31
      tests/lib/AbstractTest.php
  58. 1
      tests/phpunit.dist.xml
  59. 256
      vendor-bin/csfixer/composer.lock
  60. 267
      vendor-bin/daux/composer.lock
  61. 2
      vendor-bin/phpstan/composer.lock
  62. 201
      vendor-bin/phpunit/composer.lock
  63. 309
      vendor-bin/robo/composer.lock

10
CHANGELOG

@ -1,3 +1,13 @@
Version 0.10.3 (2022-09-14)
===========================
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
- Address CVE-2022-31090, CVE-2022-31091, CVE-2022-29248, and CVE-2022-31109
Version 0.10.2 (2022-04-04)
===========================

7
UPGRADING

@ -9,6 +9,13 @@ usually prudent:
- Check for any changes to sample systemd unit or other init files
- If installing from source, update dependencies with:
`composer install -o --no-dev`
Upgrading from 0.10.2 to 0.10.3
=============================
- The following Composer dependencies have been removed:
- laminas/laminas-diactoros
Upgrading from 0.8.5 to 0.9.0

2
composer.json

@ -28,7 +28,7 @@
"hosteurope/password-generator": "1.*",
"docopt/docopt": "1.*",
"jkingweb/druuid": "3.*",
"laminas/laminas-diactoros": "2.*",
"guzzlehttp/psr7": "1.*",
"laminas/laminas-httphandlerrunner": "1.*"
},
"require-dev": {

274
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": "c658930fbc56b2b2cf646e34c6a8d8d3",
"content-hash": "2671d9010a4ac73e877838baf3586df2",
"packages": [
{
"name": "docopt/docopt",
@ -58,24 +58,24 @@
},
{
"name": "guzzlehttp/guzzle",
"version": "6.5.5",
"version": "6.5.8",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "9d4290de1cfd701f38099ef7e183b64b4b7b0c5e"
"reference": "a52f0440530b54fa079ce76e8c5d196a42cad981"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/9d4290de1cfd701f38099ef7e183b64b4b7b0c5e",
"reference": "9d4290de1cfd701f38099ef7e183b64b4b7b0c5e",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/a52f0440530b54fa079ce76e8c5d196a42cad981",
"reference": "a52f0440530b54fa079ce76e8c5d196a42cad981",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/promises": "^1.0",
"guzzlehttp/psr7": "^1.6.1",
"guzzlehttp/psr7": "^1.9",
"php": ">=5.5",
"symfony/polyfill-intl-idn": "^1.17.0"
"symfony/polyfill-intl-idn": "^1.17"
},
"require-dev": {
"ext-curl": "*",
@ -104,10 +104,40 @@
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Jeremy Lindblom",
"email": "jeremeamia@gmail.com",
"homepage": "https://github.com/jeremeamia"
},
{
"name": "George Mponos",
"email": "gmponos@gmail.com",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
}
],
"description": "Guzzle is a PHP HTTP client library",
@ -123,9 +153,23 @@
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/6.5"
"source": "https://github.com/guzzle/guzzle/tree/6.5.8"
},
"time": "2020-06-16T21:01:06+00:00"
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
"type": "tidelift"
}
],
"time": "2022-06-20T22:16:07+00:00"
},
{
"name": "guzzlehttp/promises",
@ -213,16 +257,16 @@
},
{
"name": "guzzlehttp/psr7",
"version": "1.8.5",
"version": "1.9.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "337e3ad8e5716c15f9657bd214d16cc5e69df268"
"reference": "e98e3e6d4f86621a9b75f623996e6bbdeb4b9318"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/337e3ad8e5716c15f9657bd214d16cc5e69df268",
"reference": "337e3ad8e5716c15f9657bd214d16cc5e69df268",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/e98e3e6d4f86621a9b75f623996e6bbdeb4b9318",
"reference": "e98e3e6d4f86621a9b75f623996e6bbdeb4b9318",
"shasum": ""
},
"require": {
@ -243,7 +287,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.7-dev"
"dev-master": "1.9-dev"
}
},
"autoload": {
@ -303,7 +347,7 @@
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/1.8.5"
"source": "https://github.com/guzzle/psr7/tree/1.9.0"
},
"funding": [
{
@ -319,7 +363,7 @@
"type": "tidelift"
}
],
"time": "2022-03-20T21:51:18+00:00"
"time": "2022-06-20T21:43:03+00:00"
},
{
"name": "hosteurope/password-generator",
@ -493,105 +537,6 @@
},
"time": "2017-08-17T12:23:43+00:00"
},
{
"name": "laminas/laminas-diactoros",
"version": "2.4.1",
"source": {
"type": "git",
"url": "https://github.com/laminas/laminas-diactoros.git",
"reference": "36ef09b73e884135d2059cc498c938e90821bb57"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/36ef09b73e884135d2059cc498c938e90821bb57",
"reference": "36ef09b73e884135d2059cc498c938e90821bb57",
"shasum": ""
},
"require": {
"laminas/laminas-zendframework-bridge": "^1.0",
"php": "^7.1",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.0"
},
"conflict": {
"phpspec/prophecy": "<1.9.0"
},
"provide": {
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"replace": {
"zendframework/zend-diactoros": "^2.2.1"
},
"require-dev": {
"ext-curl": "*",
"ext-dom": "*",
"ext-gd": "*",
"ext-libxml": "*",
"http-interop/http-factory-tests": "^0.5.0",
"laminas/laminas-coding-standard": "~1.0.0",
"php-http/psr7-integration-tests": "^1.0",
"phpunit/phpunit": "^7.5.18"
},
"type": "library",
"extra": {
"laminas": {
"config-provider": "Laminas\\Diactoros\\ConfigProvider",
"module": "Laminas\\Diactoros"
}
},
"autoload": {
"files": [
"src/functions/create_uploaded_file.php",
"src/functions/marshal_headers_from_sapi.php",
"src/functions/marshal_method_from_sapi.php",
"src/functions/marshal_protocol_version_from_sapi.php",
"src/functions/marshal_uri_from_sapi.php",
"src/functions/normalize_server.php",
"src/functions/normalize_uploaded_files.php",
"src/functions/parse_cookie_header.php",
"src/functions/create_uploaded_file.legacy.php",
"src/functions/marshal_headers_from_sapi.legacy.php",
"src/functions/marshal_method_from_sapi.legacy.php",
"src/functions/marshal_protocol_version_from_sapi.legacy.php",
"src/functions/marshal_uri_from_sapi.legacy.php",
"src/functions/normalize_server.legacy.php",
"src/functions/normalize_uploaded_files.legacy.php",
"src/functions/parse_cookie_header.legacy.php"
],
"psr-4": {
"Laminas\\Diactoros\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "PSR HTTP Message implementations",
"homepage": "https://laminas.dev",
"keywords": [
"http",
"laminas",
"psr",
"psr-17",
"psr-7"
],
"support": {
"chat": "https://laminas.dev/chat",
"docs": "https://docs.laminas.dev/laminas-diactoros/",
"forum": "https://discourse.laminas.dev",
"issues": "https://github.com/laminas/laminas-diactoros/issues",
"rss": "https://github.com/laminas/laminas-diactoros/releases.atom",
"source": "https://github.com/laminas/laminas-diactoros"
},
"funding": [
{
"url": "https://funding.communitybridge.org/projects/laminas-project",
"type": "community_bridge"
}
],
"time": "2020-09-03T14:29:41+00:00"
},
{
"name": "laminas/laminas-httphandlerrunner",
"version": "1.2.0",
@ -848,61 +793,6 @@
},
"time": "2020-09-15T07:28:23+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"
],
"support": {
"source": "https://github.com/php-fig/http-factory/tree/master"
},
"time": "2019-04-30T12:38:16+00:00"
},
{
"name": "psr/http-message",
"version": "1.0.1",
@ -1109,16 +999,16 @@
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.25.0",
"version": "v1.26.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
"reference": "749045c69efb97c70d25d7463abba812e91f3a44"
"reference": "59a8d271f00dd0e4c2e518104cc7963f655a1aa8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/749045c69efb97c70d25d7463abba812e91f3a44",
"reference": "749045c69efb97c70d25d7463abba812e91f3a44",
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/59a8d271f00dd0e4c2e518104cc7963f655a1aa8",
"reference": "59a8d271f00dd0e4c2e518104cc7963f655a1aa8",
"shasum": ""
},
"require": {
@ -1132,7 +1022,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.23-dev"
"dev-main": "1.26-dev"
},
"thanks": {
"name": "symfony/polyfill",
@ -1176,7 +1066,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.25.0"
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.26.0"
},
"funding": [
{
@ -1192,20 +1082,20 @@
"type": "tidelift"
}
],
"time": "2021-09-14T14:02:44+00:00"
"time": "2022-05-24T11:49:31+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
"version": "v1.25.0",
"version": "v1.26.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
"reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8"
"reference": "219aa369ceff116e673852dce47c3a41794c14bd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8590a5f561694770bdcd3f9b5c69dde6945028e8",
"reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8",
"url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/219aa369ceff116e673852dce47c3a41794c14bd",
"reference": "219aa369ceff116e673852dce47c3a41794c14bd",
"shasum": ""
},
"require": {
@ -1217,7 +1107,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.23-dev"
"dev-main": "1.26-dev"
},
"thanks": {
"name": "symfony/polyfill",
@ -1260,7 +1150,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.25.0"
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.26.0"
},
"funding": [
{
@ -1276,20 +1166,20 @@
"type": "tidelift"
}
],
"time": "2021-02-19T12:13:01+00:00"
"time": "2022-05-24T11:49:31+00:00"
},
{
"name": "symfony/polyfill-php72",
"version": "v1.25.0",
"version": "v1.26.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php72.git",
"reference": "9a142215a36a3888e30d0a9eeea9766764e96976"
"reference": "bf44a9fd41feaac72b074de600314a93e2ae78e2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/9a142215a36a3888e30d0a9eeea9766764e96976",
"reference": "9a142215a36a3888e30d0a9eeea9766764e96976",
"url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/bf44a9fd41feaac72b074de600314a93e2ae78e2",
"reference": "bf44a9fd41feaac72b074de600314a93e2ae78e2",
"shasum": ""
},
"require": {
@ -1298,7 +1188,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.23-dev"
"dev-main": "1.26-dev"
},
"thanks": {
"name": "symfony/polyfill",
@ -1336,7 +1226,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php72/tree/v1.25.0"
"source": "https://github.com/symfony/polyfill-php72/tree/v1.26.0"
},
"funding": [
{
@ -1352,7 +1242,7 @@
"type": "tidelift"
}
],
"time": "2021-05-27T09:17:38+00:00"
"time": "2022-05-24T11:49:31+00:00"
}
],
"packages-dev": [
@ -1424,5 +1314,5 @@
"platform-overrides": {
"php": "7.1.33"
},
"plugin-api-version": "2.2.0"
"plugin-api-version": "2.3.0"
}

1
docs/en/030_Supported_Protocols/005_Miniflux.md

@ -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

2
docs/en/030_Supported_Protocols/010_Nextcloud_News.md

@ -10,7 +10,7 @@
<dt>API endpoint</dt>
<dd>/index.php/apps/news/api/v1-2/</dd>
<dt>Specifications</dt>
<dd><a href="https://github.com/nextcloud/news/blob/master/docs/externalapi/Legacy.md">Version 1.2</a></dd>
<dd><a href="https://github.com/nextcloud/news/blob/master/docs/api/api-v1-2.md">Version 1.2</a></dd>
</dl>
The Nextcloud News protocol was the first supported by The Arsse, and has been supported in full since version 0.3.0.

4
docs/en/030_Supported_Protocols/020_Tiny_Tiny_RSS.md

@ -10,7 +10,7 @@
<dt>API endpoint</dt>
<dd>/tt-rss/api</dd>
<dt>Specifications</dt>
<dd><a href="https://git.tt-rss.org/git/tt-rss/wiki/ApiReference">Main</a>, <a href="https://git.tt-rss.org/fox/tt-rss/wiki/SearchSyntax">search syntax</a>, <a href="https://github.com/jangernert/FeedReader/blob/master/data/tt-rss-feedreader-plugin/README.md">FeedReader extensions</a>, <a href="https://github.com/hrk/tt-rss-newsplus-plugin/blob/master/README.md">News+ extension</a></dd>
<dd><a href="https://tt-rss.org/wiki/ApiReference">Main</a>, <a href="https://tt-rss.org/wiki/SearchSyntax">search syntax</a>, <a href="https://github.com/jangernert/FeedReader/blob/master/data/tt-rss-feedreader-plugin/README.md">FeedReader extensions</a>, <a href="https://github.com/hrk/tt-rss-newsplus-plugin/blob/master/README.md">News+ extension</a></dd>
</dl>
The Arsse supports not only the Tiny Tiny RSS protocol, but also extensions required by the FeedReader client and the more commonly supported `getCompactHeadlines` extension.
@ -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

4
docs/en/040_Compatible_Clients.md

@ -121,14 +121,14 @@ The Arsse does not at this time have any first party clients. However, because T
</td>
</tr>
<tr>
<td><a href="https://reeder.app/">Reeder</a></td>
<td><a href="https://reeder.app/">Reeder 3</a></td>
<td>macOS</td>
<td class="N"></td>
<td class="N"></td>
<td class="N"></td>
<td class="Y"></td>
<td>
<p>Also available for iOS.</p>
<p>Also available for iOS. Reeder 5 no longer supports the Fever protocol.</p>
</td>
</tr>
<tr>

2
lib/Arsse.php

@ -7,7 +7,7 @@ declare(strict_types=1);
namespace JKingWeb\Arsse;
class Arsse {
public const VERSION = "0.10.2";
public const VERSION = "0.10.3";
public const REQUIRED_EXTENSIONS = [
"intl", // as this extension is required to prepare formatted messages, its absence will throw a distinct English-only exception
"dom",

27
lib/Context/AbstractContext.php

@ -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]);
}
}
}

35
lib/Context/BooleanMembers.php

@ -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);
}
}

40
lib/Context/Context.php

@ -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);
}
}

250
lib/Context/ExclusionContext.php

@ -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);
}
}

262
lib/Context/ExclusionMembers.php

@ -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);
}
}

20
lib/Context/RootContext.php

@ -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);
}
}

50
lib/Context/UnionContext.php

@ -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;
}
}

610
lib/Database.php

@ -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,
@ -791,9 +814,15 @@ class Database {
// validate inputs
$folder = $this->folderValidateId($user, $folder)['id'];
// 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 +835,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
@ -823,23 +852,21 @@ class Database {
select
subscription,
sum(hidden) as hidden,
sum(cast((\"read\" = 1 and hidden = 0) as $integer)) as marked
sum(case when \"read\" = 1 and hidden = 0 then 1 else 0 end) 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 +884,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 +1482,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 +1502,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 +1520,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