Compare commits
123 commits
Author | SHA1 | Date | |
---|---|---|---|
9107b058f5 | |||
f066aa6649 | |||
2aa4fc9215 | |||
ba4d3f497b | |||
ba42b7b0c1 | |||
835f6fb998 | |||
7966947022 | |||
7f38369935 | |||
9e5c841377 | |||
b306f0fc29 | |||
9c25b7b6e6 | |||
1f137ba710 | |||
93c322bdfa | |||
f1d3055f4c | |||
1cd3f29fe3 | |||
2d7b2dde39 | |||
e8be7d0f38 | |||
1ae3c33344 | |||
aed3749da8 | |||
b68a68b500 | |||
ade1c79361 | |||
0170ec19c7 | |||
8834a65e4c | |||
b1154359e4 | |||
3838cdc0af | |||
1f9beb85a5 | |||
2670ed4ab8 | |||
4df97eefcb | |||
82f94d1dae | |||
7f6b36d4da | |||
06ec67816a | |||
e647d79e1e | |||
f853851568 | |||
4131531bff | |||
9086a5d9b1 | |||
cce48878e7 | |||
185ae88ba3 | |||
29fb134633 | |||
72dd21686e | |||
9d92c1661d | |||
7e58dd800d | |||
ece19494d7 | |||
1a981bf267 | |||
9951b44932 | |||
692af39768 | |||
73446887f5 | |||
e20937f98f | |||
2ff3286aa9 | |||
174ed544b2 | |||
5579144fee | |||
7613142221 | |||
d9b90390e7 | |||
59a9329032 | |||
be3adf7026 | |||
eb371b75fe | |||
1b80ad37bc | |||
3c83fc9139 | |||
711f87aad8 | |||
0a8d19d37d | |||
fe06ffc176 | |||
0d6f8d2921 | |||
92b1a840a1 | |||
a25e777ec6 | |||
44e2c9c13e | |||
866800dcc5 | |||
136d3782e3 | |||
3be3f43bab | |||
d2f3f19128 | |||
459e44e041 | |||
56f015bfb9 | |||
64ec3f6ae4 | |||
4d18bf27e2 | |||
e588a52e88 | |||
6c0183faea | |||
560d4db139 | |||
2557c22410 | |||
4ca7b65a65 | |||
4d37ae30ae | |||
d1da6fbe5e | |||
d54733ad98 | |||
a0c31fac5d | |||
59358ec35b | |||
90b66241b3 | |||
761b3d5333 | |||
d64dc751f9 | |||
f51acb4264 | |||
300225439c | |||
c6cc2a1a42 | |||
a44fe103d8 | |||
630536d789 | |||
206c5c0012 | |||
0c8f33c37c | |||
26e431b1a5 | |||
336207741d | |||
6863c182d7 | |||
f2aad7188c | |||
65b1bb4fcd | |||
2c5b9a6768 | |||
17832ac63e | |||
e65069885b | |||
7e5d8494c4 | |||
e6505a5fda | |||
2acacd2647 | |||
f6799e2ab1 | |||
33a3478a58 | |||
2489743d0f | |||
0bd01849bb | |||
895c045c9b | |||
fe02613214 | |||
427bddd3b7 | |||
53ba591720 | |||
97dfef3267 | |||
396ca86482 | |||
4a87926dd5 | |||
6f1332c559 | |||
308b592b18 | |||
983fa58ec8 | |||
2c2bb4a856 | |||
c993168002 | |||
73497688fc | |||
4080b2d09d | |||
73731fa9db | |||
18d296dcd6 |
275 changed files with 9504 additions and 7780 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -7,11 +7,12 @@
|
|||
/dist/arch/arsse/
|
||||
/dist/arch/src/
|
||||
/dist/arch/pkg/
|
||||
/dist/man/
|
||||
/arsse.db*
|
||||
/config.php
|
||||
/.php_cs.cache
|
||||
/.php-cs-fixer.cache
|
||||
/tests/.phpunit.result.cache
|
||||
/tests/.phpunit.cache
|
||||
|
||||
# Dependencies
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse;
|
||||
|
||||
const BASE = __DIR__.DIRECTORY_SEPARATOR;
|
||||
|
@ -13,7 +14,10 @@ $paths = [
|
|||
BASE."arsse.php",
|
||||
BASE."RoboFile.php",
|
||||
BASE."lib",
|
||||
BASE."tests",
|
||||
BASE."tests/cases",
|
||||
BASE."tests/lib",
|
||||
BASE."tests/bootstrap.php",
|
||||
BASE."tests/server.php",
|
||||
];
|
||||
$rules = [
|
||||
// house rules where PSR series is silent
|
||||
|
@ -35,6 +39,7 @@ $rules = [
|
|||
'no_blank_lines_after_phpdoc' => true,
|
||||
'no_empty_comment' => true,
|
||||
'no_empty_phpdoc' => true,
|
||||
'no_empty_statement' => true,
|
||||
'no_extra_blank_lines' => true, // this could probably use more configuration
|
||||
'no_mixed_echo_print' => ['use' => "echo"],
|
||||
'no_short_bool_cast' => true,
|
||||
|
@ -48,30 +53,19 @@ $rules = [
|
|||
'pow_to_exponentiation' => true,
|
||||
'set_type_to_cast' => true,
|
||||
'standardize_not_equals' => true,
|
||||
'trailing_comma_in_multiline_array' => true,
|
||||
'trailing_comma_in_multiline' => ['elements' => ["arrays"]],
|
||||
'unary_operator_spaces' => true,
|
||||
'yoda_style' => false,
|
||||
// PSR standard to apply
|
||||
'@PSR2' => true,
|
||||
// PSR-12 rules; php-cs-fixer does not yet support PSR-12 natively
|
||||
'compact_nullable_typehint' => true,
|
||||
'declare_equal_normalize' => ['space' => "none"],
|
||||
'function_typehint_space' => true,
|
||||
'lowercase_cast' => true,
|
||||
'lowercase_static_reference' => true,
|
||||
'no_alternative_syntax' => true,
|
||||
'no_empty_statement' => true,
|
||||
'no_leading_import_slash' => true,
|
||||
'no_leading_namespace_whitespace' => true,
|
||||
'no_whitespace_in_blank_line' => true,
|
||||
'return_type_declaration' => ['space_before' => "none"],
|
||||
'single_trait_insert_per_statement' => true,
|
||||
'short_scalar_cast' => true,
|
||||
'visibility_required' => ['elements' => ["const", "property", "method"]],
|
||||
'@PSR12' => true,
|
||||
// house exceptions to PSR rules
|
||||
'braces' => ['position_after_functions_and_oop_constructs' => "same"],
|
||||
'curly_braces_position' => [
|
||||
'functions_opening_brace' => "same_line",
|
||||
'classes_opening_brace' => "same_line",
|
||||
],
|
||||
'function_declaration' => ['closure_function_spacing' => "none"],
|
||||
'new_with_braces' => false, // no option to specify absence of braces
|
||||
'php_unit_attributes' => true,
|
||||
];
|
||||
|
||||
$finder = \PhpCsFixer\Finder::create();
|
||||
|
@ -82,4 +76,4 @@ foreach ($paths as $path) {
|
|||
$finder = $finder->in($path);
|
||||
}
|
||||
}
|
||||
return \PhpCsFixer\Config::create()->setRiskyAllowed(true)->setRules($rules)->setFinder($finder);
|
||||
return (new \PhpCsFixer\Config)->setRiskyAllowed(true)->setRules($rules)->setFinder($finder);
|
37
CHANGELOG
37
CHANGELOG
|
@ -1,3 +1,38 @@
|
|||
Version 0.10.6 (2024-12-27)
|
||||
===========================
|
||||
|
||||
Bug fixes:
|
||||
- Do not hang when language files are missing or corrupted
|
||||
|
||||
Changes:
|
||||
- Support PHP 8.4
|
||||
- Support MySQL 9.0
|
||||
|
||||
Version 0.10.5 (2024-01-10)
|
||||
===========================
|
||||
|
||||
Changes:
|
||||
- Require PHP 7.3
|
||||
- Adapt the Arch package to make using alternative PHP interpreters easier
|
||||
(see manual for details)
|
||||
- Multiple editorial and stylistic changes to the UNIX manual page
|
||||
|
||||
Version 0.10.4 (2023-01-24)
|
||||
===========================
|
||||
|
||||
Changes:
|
||||
- Support PHP 8.2
|
||||
|
||||
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)
|
||||
===========================
|
||||
|
||||
|
@ -56,7 +91,7 @@ Bug fixes:
|
|||
- Further relax Fever HTTP correctness, to fix more clients
|
||||
- Use icons specified in Atom feeds when available
|
||||
- Do not return null as subscription unread count
|
||||
- Explicitly forbid U+003A COLON and control characters in usernames, for
|
||||
- Explicitly forbid U+003A COLON and control characters in usernames, for
|
||||
compatibility with RFC 7617
|
||||
- Never return 401 in response to an OPTIONS request
|
||||
- Accept "t" and "f" as booleans in Tiny Tiny RSS
|
||||
|
|
37
README.md
37
README.md
|
@ -34,13 +34,13 @@ Also necessary to the functioning of the application is the `/vendor/` directory
|
|||
|
||||
The `/locale/` and `/sql/` directories contain human-language files and database schemata, both of which are occasionally used by the application in the course of execution. The `/www/` directory serves as a document root for a few static files to be made available to users by a Web server.
|
||||
|
||||
The `/dist/` directory, on the other hand, contains general and system-specific build files, and samples of configuration for Web servers and other system integration. These are not used by The Arsse itself, but are used during the process of preparing new releases for supported operating systems.
|
||||
The `/dist/` directory, on the other hand, contains general and system-specific build files, manual pages, and samples of configuration for Web servers and other system integration. These are not used by The Arsse itself, but are used during the process of preparing new releases for supported operating systems.
|
||||
|
||||
## Documentation
|
||||
|
||||
The source text for The Arsse's manual can be found in `/docs/`, with pages written in [Markdown](https://spec.commonmark.org/current/) and converted to HTML [with Daux](#building-the-manual). If a static manual is generated its files will appear under `/manual/`.
|
||||
|
||||
The Arsse also has a UNIX manual page, also written in Markdown, which can be found under `/manpages/`. [Pandoc](https://pandoc.org/) is needed to convert it to the appropriate format, with the results stored under `/dist/man/`.
|
||||
The Arsse also has a UNIX manual page written in [mdoc](https://man.archlinux.org/man/extra/mandoc/mandoc_mdoc.7.en) format, which can be found under `/dist/man/`.
|
||||
|
||||
In addition to the manuals the files `/README.md` (this file), `/CHANGELOG`, `/UPGRADING`, `/LICENSE`, and `/AUTHORS` also document various things about the software, rather than the software itself.
|
||||
|
||||
|
@ -64,17 +64,17 @@ PHPUnit's configuration can be customized by copying its configuration file to `
|
|||
|
||||
The `/vendor-bin/` directory houses the files needed for the tools used in The Arsse's programming environment. These are managed by the Composer ["bin" plugin](https://github.com/bamarni/composer-bin-plugin) and are not used by The Arsse itself. The following files are also related to various programming tools:
|
||||
|
||||
| Path | Description |
|
||||
|-------------------|----------------------------------------------------------|
|
||||
| `/.gitattributes` | Git settings for handling files |
|
||||
| `/.gitignore` | Git file exclusion patterns |
|
||||
| `/.php_cs.dist` | Configuration for [php-cs-fixer](https://cs.symfony.com) |
|
||||
| `/.php_cs.cache` | Cache for php-cs-fixer |
|
||||
| `/composer.json` | Configuration for Composer |
|
||||
| `/composer.lock` | Version synchronization data for Composer |
|
||||
| `/RoboFile.php` | Task definitions for [Robo](https://robo.li/) |
|
||||
| `/robo` | Simple wrapper for executing Robo on POSIX systems |
|
||||
| `/robo.bat` | Simple wrapper for executing Robo on Windows |
|
||||
| Path | Description |
|
||||
|---------------------------|----------------------------------------------------------|
|
||||
| `/.gitattributes` | Git settings for handling files |
|
||||
| `/.gitignore` | Git file exclusion patterns |
|
||||
| `/.php-cs-fixer.dist.php` | Configuration for [php-cs-fixer](https://cs.symfony.com) |
|
||||
| `/.php-cs-fixer.cache` | Cache for php-cs-fixer |
|
||||
| `/composer.json` | Configuration for Composer |
|
||||
| `/composer.lock` | Version synchronization data for Composer |
|
||||
| `/RoboFile.php` | Task definitions for [Robo](https://robo.li/) |
|
||||
| `/robo` | Simple wrapper for executing Robo on POSIX systems |
|
||||
| `/robo.bat` | Simple wrapper for executing Robo on Windows |
|
||||
|
||||
In addition the files `/package.json` and `/postcss.config.js` as well as the `/node_modules/` directory are used by [Yarn](https://yarnpkg.com/) and [PostCSS](https://postcss.org/) when modifying the stylesheet for The Arsse's manual.
|
||||
|
||||
|
@ -90,11 +90,11 @@ There is also a `test:quick` Robo task which excludes slower tests, and a `test:
|
|||
|
||||
### Test coverage
|
||||
|
||||
Computing the coverage of tests can be done by running `./robo coverage`, after which an HTML-format coverage report will be written to `/tests/coverage/`. Either [PCOV](https://github.com/krakjoe/pcov), [Xdebug](https://xdebug.org), or [phpdbg](https://php.net/manual/en/book.phpdbg.php) is required for this. PCOV is generally recommended as it is faster than Xdebug; phpdbg is faster still, but less accurate. If using either PCOV or Xdebug, the extension need not be enabled globally; PHPUnit will enable it when needed.
|
||||
Computing the coverage of tests can be done by running `./robo coverage`, after which an HTML-format coverage report will be written to `/tests/coverage/`. Either [PCOV](https://github.com/krakjoe/pcov) or [Xdebug](https://xdebug.org) is required for this. PCOV is generally recommended as it is faster than Xdebug. Neither extension need be enabled globally; Robo will enable it when needed.
|
||||
|
||||
## Enforcing coding style
|
||||
|
||||
The [php-cs-fixer](https://cs.symfony.com) tool, executed via `./robo clean`, can be used to rewrite code to adhere to The Arsse's coding style. The style largely follows [PSR-2](https://www.php-fig.org/psr/psr-2/) with some exceptions:
|
||||
The [php-cs-fixer](https://cs.symfony.com) tool, executed via `./robo clean`, can be used to rewrite code to adhere to The Arsse's coding style. The style largely follows [PSR-12](https://www.php-fig.org/psr/psr-12/) with some exceptions:
|
||||
|
||||
- Classes, methods, and functions should have their opening brace on the same line as the signature
|
||||
- Anonymous functions should have no space before the parameter list
|
||||
|
@ -107,20 +107,13 @@ The Arsse's user manual, made using [Daux](https://daux.io/), can be compiled by
|
|||
|
||||
The manual employs a custom theme derived from the standard Daux theme. If the standard Daux theme receives improvements, the custom theme can be rebuilt by running `./robo manual:theme`. This requires that [NodeJS](https://nodejs.org) and [Yarn](https://yarnpkg.com/) be installed, but JavaScript tools are not required to modify The Arsse itself, nor the content of the manual.
|
||||
|
||||
## Building the man page
|
||||
|
||||
The Arsse's UNIX manual page is authored in Markdown, and must be converted to the native roff format using [Pandoc](https://pandoc.org/). This can be done by running `./robo manpage`, which will output appropriate files to `/dist/man/`. The conversion should not be done manually as there is post-processing required for optimal output.
|
||||
|
||||
## Packaging a release
|
||||
|
||||
Producing release packages is done by running `./robo package`. This performs the following operations:
|
||||
|
||||
- Duplicates a [Git](https://git-scm.com/) working tree with the commit (usually a release tag) to package
|
||||
- Generates UNIX manual pages with [Pandoc](https://pandoc.org/)
|
||||
- Generates the HTML manual
|
||||
- Installs runtime Composer dependencies with an optimized autoloader
|
||||
- Deletes numerous unneeded files
|
||||
- Exports the default configuration of The Arsse to a file
|
||||
- Compresses the remaining files into a tarball
|
||||
- Produces a binary package for Arch Linux, if possible
|
||||
- Produces source and binary packages for Debian using [pbuilder](https://pbuilder-team.pages.debian.net/pbuilder/), if possible
|
||||
|
|
150
RoboFile.php
150
RoboFile.php
|
@ -55,8 +55,8 @@ class RoboFile extends \Robo\Tasks {
|
|||
* tests/coverage/. Additional reports may be produced by passing
|
||||
* arguments to this task as one would to PHPUnit.
|
||||
*
|
||||
* Robo first tries to use pcov and will fall back first to xdebug then
|
||||
* phpdbg. Neither pcov nor xdebug need to be enabled to be used; they
|
||||
* Robo first tries to use pcov and will fall back to xdebug.
|
||||
* Neither pcov nor xdebug need to be enabled to be used; they
|
||||
* only need to be present in the extension load path to be used.
|
||||
*/
|
||||
public function coverage(array $args): Result {
|
||||
|
@ -80,9 +80,9 @@ class RoboFile extends \Robo\Tasks {
|
|||
return $this->runTests($exec, "typical", array_merge(["--coverage-html", BASE_TEST."coverage"], $args));
|
||||
}
|
||||
|
||||
/** Runs the coding standards fixer */
|
||||
/** Runs the coding-style fixer */
|
||||
public function clean($opts = ['demo|d' => false]): Result {
|
||||
$t = $this->taskExec(norm(BASE."vendor/bin/php-cs-fixer"));
|
||||
$t = $this->taskExec(norm(BASE."vendor-bin/csfixer/vendor/bin/php-cs-fixer"));
|
||||
$t->arg("fix");
|
||||
if ($opts['demo']) {
|
||||
$t->args("--dry-run", "--diff")->option("--diff-format", "udiff");
|
||||
|
@ -90,9 +90,10 @@ class RoboFile extends \Robo\Tasks {
|
|||
return $t->run();
|
||||
}
|
||||
|
||||
/** Finds the first suitable means of computing code coverage, either pcov or xdebug. */
|
||||
protected function findCoverageEngine(): string {
|
||||
$dir = rtrim(ini_get("extension_dir"), "/").\DIRECTORY_SEPARATOR;
|
||||
$ext = IS_WIN ? "dll" : (IS_MAC ? "dylib" : "so");
|
||||
$ext = IS_WIN ? "dll" : "so";
|
||||
$php = escapeshellarg(\PHP_BINARY);
|
||||
$code = escapeshellarg(BASE."lib");
|
||||
if (extension_loaded("pcov")) {
|
||||
|
@ -104,48 +105,61 @@ class RoboFile extends \Robo\Tasks {
|
|||
} elseif (file_exists($dir."xdebug.$ext")) {
|
||||
return "$php -d zend_extension=xdebug.$ext -d xdebug.mode=coverage";
|
||||
} else {
|
||||
if (IS_WIN) {
|
||||
$dbg = dirname(\PHP_BINARY)."\\phpdbg.exe";
|
||||
$dbg = file_exists($dbg) ? $dbg : "";
|
||||
} else {
|
||||
$dbg = trim(`which phpdbg 2>/dev/null`);
|
||||
}
|
||||
if ($dbg) {
|
||||
return escapeshellarg($dbg)." -qrr";
|
||||
} else {
|
||||
return $php;
|
||||
}
|
||||
return $php;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the necessary shell arguments to print error output or all output to the bitbucket
|
||||
*
|
||||
* @param bool $all Whether all output (true) or only error output (false) should be suppressed
|
||||
*/
|
||||
protected function blackhole(bool $all = false): string {
|
||||
$hole = IS_WIN ? "nul" : "/dev/null";
|
||||
return $all ? ">$hole 2>&1" : "2>$hole";
|
||||
}
|
||||
|
||||
/** Executes PHPUnit, used by the test and coverage tasks.
|
||||
*
|
||||
* This also executes the built-in PHP Web server, which is required to fetch some newsfeeds during tests
|
||||
*
|
||||
* @param string $executor The path to the PHP binary to execute with any required extra arguments. Normally this is either "php" or the result of findCoverageEngine()
|
||||
* @param string $set The set of tests to run, either "typical" (excludes redundant tests), "quick" (excludes redundant and slow tests), "coverage" (excludes tests not needed for coverage), or "full" (all tests)
|
||||
* @param array $args Extra arguments passed by Robo from the command line
|
||||
*/
|
||||
protected function runTests(string $executor, string $set, array $args): Result {
|
||||
switch ($set) {
|
||||
case "typical":
|
||||
$set = ["--exclude-group", "optional"];
|
||||
$exc = ["optional"];
|
||||
break;
|
||||
case "quick":
|
||||
$set = ["--exclude-group", "optional,slow"];
|
||||
$exc = ["optional", "slow"];
|
||||
break;
|
||||
case "coverage":
|
||||
$set = ["--exclude-group", "optional,coverageOptional"];
|
||||
$exc = ["optional", "coverageOptional"];
|
||||
break;
|
||||
case "full":
|
||||
$set = [];
|
||||
$exc = [];
|
||||
break;
|
||||
default:
|
||||
throw new \Exception;
|
||||
}
|
||||
$extra = ["--display-phpunit-deprecations"];
|
||||
foreach ($exc as $group) {
|
||||
$extra[] = "--exclude-group";
|
||||
$extra[] = $group;
|
||||
}
|
||||
$execpath = norm(BASE."vendor-bin/phpunit/vendor/phpunit/phpunit/phpunit");
|
||||
$confpath = realpath(BASE_TEST."phpunit.dist.xml") ?: norm(BASE_TEST."phpunit.xml");
|
||||
$this->taskServer(8000)->host("localhost")->dir(BASE_TEST."docroot")->rawArg("-n")->arg(BASE_TEST."server.php")->rawArg($this->blackhole())->background()->run();
|
||||
return $this->taskExec($executor)->option("-d", "zend.assertions=1")->arg($execpath)->option("-c", $confpath)->args(array_merge($set, $args))->run();
|
||||
return $this->taskExec($executor)->option("-d", "zend.assertions=1")->arg($execpath)->option("-c", $confpath)->args(array_merge($extra, $args))->run();
|
||||
}
|
||||
|
||||
/** Returns a Git version string for a given Git tree-ish ID
|
||||
*
|
||||
* Returns an array containing the tree-ish string and the version string.
|
||||
*
|
||||
* @param string|null $commit The tree-ish ID. If not supplied the user will be prompted
|
||||
*/
|
||||
protected function commitVersion(?string $commit): array {
|
||||
$target = $commit ?? $this->askDefault("Reference commit:", "HEAD");
|
||||
$base = escapeshellarg(BASE);
|
||||
|
@ -158,13 +172,11 @@ class RoboFile extends \Robo\Tasks {
|
|||
return [$target, $version];
|
||||
}
|
||||
|
||||
/** Checks whether all the supplied terminal commands are available in path */
|
||||
protected function toolExists(string ...$binary): bool {
|
||||
$blackhole = $this->blackhole(IS_WIN);
|
||||
foreach ($binary as $bin) {
|
||||
if (
|
||||
(IS_WIN && (!exec(escapeshellarg($bin)." --help $blackhole", $junk, $status) || $status))
|
||||
|| (!IS_WIN && (!exec("which ".escapeshellarg($bin)." $blackhole", $junk, $status) || $status))
|
||||
) {
|
||||
if (!exec(escapeshellarg($bin)." --help $blackhole", $junk, $status) || $status) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -181,9 +193,9 @@ class RoboFile extends \Robo\Tasks {
|
|||
* may not be equivalent due to subsequent changes in the exclude list, or because
|
||||
* of new tooling.
|
||||
*/
|
||||
public function packageGeneric(string $commit = null): Result {
|
||||
if (!$this->toolExists("git", "pandoc")) {
|
||||
throw new \Exception("Git and Pandoc are required in PATH to produce generic release tarballs");
|
||||
public function packageGeneric(?string $commit = null): Result {
|
||||
if (!$this->toolExists("git")) {
|
||||
throw new \Exception("Git is required in PATH to produce generic release tarballs");
|
||||
}
|
||||
// establish which commit to package
|
||||
[$commit, $version] = $this->commitVersion($commit);
|
||||
|
@ -230,17 +242,17 @@ class RoboFile extends \Robo\Tasks {
|
|||
}
|
||||
// save commit description to VERSION file for reference
|
||||
$t->addTask($this->taskWriteToFile($dir."VERSION")->text($version));
|
||||
if (file_exists($dir."docs") || file_exists($dir."manpages")) {
|
||||
if (file_exists($dir."docs") || file_exists($dir."manpages/en.md")) {
|
||||
// perform Composer installation in the temp location with dev dependencies to include Robo and Daux
|
||||
$t->addTask($this->taskExec("composer install")->arg("-q")->dir($dir));
|
||||
}
|
||||
if (file_exists($dir."manpages")) {
|
||||
// generate manpages
|
||||
$t->addTask($this->taskExec("./robo manpage")->dir($dir));
|
||||
}
|
||||
if (file_exists($dir."docs")) {
|
||||
// generate the HTML manual
|
||||
$t->addTask($this->taskExec("./robo manual -q")->dir($dir));
|
||||
if (file_exists($dir."docs")) {
|
||||
// generate the HTML manual
|
||||
$t->addTask($this->taskExec("./robo manual -q")->dir($dir));
|
||||
}
|
||||
if (file_exists($dir."manpages/en.md")) {
|
||||
// generate manpages (NOTE: obsolete process)
|
||||
$t->addTask($this->taskExec("./robo manpage")->dir($dir));
|
||||
}
|
||||
}
|
||||
// perform Composer installation in the temp location for final output
|
||||
$t->addTask($this->taskExec("composer install")->dir($dir)->arg("--no-dev")->arg("-o")->arg("--no-scripts")->arg("-q"));
|
||||
|
@ -301,7 +313,7 @@ class RoboFile extends \Robo\Tasks {
|
|||
* or any commit hash. If none is provided on the command line, Robo will prompt
|
||||
* for a commit to package; the default is "HEAD".
|
||||
*/
|
||||
public function packageDebsrc(string $commit = null): Result {
|
||||
public function packageDebsrc(?string $commit = null): Result {
|
||||
// establish which commit to package
|
||||
[$commit, $version] = $this->commitVersion($commit);
|
||||
$tarball = BASE."release/$version/arsse-$version.tar.gz";
|
||||
|
@ -355,7 +367,7 @@ class RoboFile extends \Robo\Tasks {
|
|||
* Build Service instances and with slight modification the Arch User Repository.
|
||||
* Use for Launchpad PPAs has not been tested.
|
||||
*/
|
||||
public function package(string $commit = null): Result {
|
||||
public function package(?string $commit = null): Result {
|
||||
if (!$this->toolExists("git")) {
|
||||
throw new \Exception("Git is required in PATH to produce packages");
|
||||
}
|
||||
|
@ -400,12 +412,12 @@ class RoboFile extends \Robo\Tasks {
|
|||
return $t->run();
|
||||
}
|
||||
|
||||
/** Generates static manual pages in the "manual" directory
|
||||
/** Generates static HTML manual pages in the "manual" directory
|
||||
*
|
||||
* The resultant files are suitable for offline viewing and inclusion into release builds
|
||||
*/
|
||||
public function manual(array $args): Result {
|
||||
$execpath = escapeshellarg(norm(BASE."vendor/bin/daux"));
|
||||
$execpath = escapeshellarg(norm(BASE."vendor-bin/daux/vendor/bin/daux"));
|
||||
$t = $this->collectionBuilder();
|
||||
$t->taskExec($execpath)->arg("generate")->option("-d", BASE."manual")->args($args);
|
||||
$t->taskDeleteDir(BASE."manual/daux_libraries");
|
||||
|
@ -416,7 +428,7 @@ class RoboFile extends \Robo\Tasks {
|
|||
|
||||
/** Serves a live view of the manual using the built-in Web server */
|
||||
public function manualLive(array $args): Result {
|
||||
$execpath = escapeshellarg(norm(BASE."vendor/bin/daux"));
|
||||
$execpath = escapeshellarg(norm(BASE."vendor-bin/daux/vendor/bin/daux"));
|
||||
return $this->taskExec($execpath)->arg("serve")->args($args)->run();
|
||||
}
|
||||
|
||||
|
@ -447,29 +459,15 @@ class RoboFile extends \Robo\Tasks {
|
|||
return $t->run();
|
||||
}
|
||||
|
||||
/** Generates the "arsse" command's manual page (UNIX man page)
|
||||
/** Parses the contents of the CHANGELOG file into an array structure
|
||||
*
|
||||
* This requires that the Pandoc document converter be installed and
|
||||
* available in $PATH.
|
||||
* This is done line-by-line and tends to be quite strict.
|
||||
* The parsed output can be used to generate changelogs in other formats,
|
||||
* such as a Debian changelog or RPM changelog.
|
||||
*
|
||||
* @param string $text The text of the CHANGELOG file
|
||||
* @param string $targetVersion The x.y.z version number of the latest release. This is used to check that version numbers and dates have been updated when preparing a release
|
||||
*/
|
||||
public function manpage(): Result {
|
||||
if (!$this->toolExists("pandoc")) {
|
||||
throw new \Exception("Pandoc is required in PATH to generate manual pages");
|
||||
}
|
||||
$t = $this->collectionBuilder();
|
||||
$man = [
|
||||
'en' => "man1/arsse.1",
|
||||
];
|
||||
foreach ($man as $src => $out) {
|
||||
$src = BASE."manpages/$src.md";
|
||||
$out = BASE."dist/man/$out";
|
||||
$t->addTask($this->taskFilesystemStack()->mkdir(dirname($out), 0755));
|
||||
$t->addTask($this->taskExec("pandoc -s -f markdown-smart -t man -o ".escapeshellarg($out)." ".escapeshellarg($src)));
|
||||
$t->addTask($this->taskReplaceInFile($out)->regex('/\.\n(?!\.)/s')->to(". "));
|
||||
}
|
||||
return $t->run();
|
||||
}
|
||||
|
||||
protected function changelogParse(string $text, string $targetVersion): array {
|
||||
$lines = preg_split('/\r?\n/', $text);
|
||||
$version = "";
|
||||
|
@ -479,7 +477,7 @@ class RoboFile extends \Robo\Tasks {
|
|||
$expected = ["version"];
|
||||
for ($a = 0; $a < sizeof($lines);) {
|
||||
$l = rtrim($lines[$a++]);
|
||||
if (in_array("version", $expected) && preg_match('/^Version (\d+(?:\.\d+)*) \(([\d\?]{4}-[\d\?]{2}-[\d\?]{2})\)\s*$/D', $l, $m)) {
|
||||
if (in_array("version", $expected) && preg_match('/^Version ([\d\?]+(?:\.[\d\?]+)*) \(([\d\?]{4}-[\d\?]{2}-[\d\?]{2})\)\s*$/D', $l, $m)) {
|
||||
$version = $m[1];
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/D', $m[2])) {
|
||||
// uncertain dates are allowed only for the top version, and only if it does not match the target version (otherwise we have forgotten to set the correct date before tagging)
|
||||
|
@ -524,7 +522,7 @@ class RoboFile extends \Robo\Tasks {
|
|||
} elseif (in_array("item", $expected) && preg_match('/^- (\w.*)$/D', $l, $m)) {
|
||||
$entry[$section][] = $m[1];
|
||||
$expected = ["item", "continuation", "blank line"];
|
||||
} elseif (in_array("continuation", $expected) && preg_match('/^ (\w.*)$/D', $l, $m)) {
|
||||
} elseif (in_array("continuation", $expected) && preg_match('/^ (\S.*)$/D', $l, $m)) {
|
||||
$last = sizeof($entry[$section]) - 1;
|
||||
$entry[$section][$last] .= "\n".$m[1];
|
||||
} else {
|
||||
|
@ -546,7 +544,18 @@ class RoboFile extends \Robo\Tasks {
|
|||
return $out;
|
||||
}
|
||||
|
||||
/** Produce a Debian changelog from a parsed CHANGELOG file
|
||||
*
|
||||
* The Debian changelog format is extremely specific with certain tokens
|
||||
* having special meaning and leading whitespace also being significant.
|
||||
* Modifying this function should be done with extreme care.
|
||||
*
|
||||
* @param array $log The parsed chaneglog, output by changelogParse()
|
||||
* @param string $targetVersion The second output of commitVersion()
|
||||
*/
|
||||
protected function changelogDebian(array $log, string $targetVersion): string {
|
||||
$authorName = "J. King";
|
||||
$authorMail = "jking@jkingweb.ca";
|
||||
$latest = $log[0]['version'];
|
||||
$baseVersion = preg_replace('/^(\d+(?:\.\d+)*).*/', "$1", $targetVersion);
|
||||
if ($baseVersion !== $targetVersion && version_compare($latest, $baseVersion, ">")) {
|
||||
|
@ -582,11 +591,20 @@ class RoboFile extends \Robo\Tasks {
|
|||
$out .= " * ".trim(preg_replace("/^/m", " ", $item))."\n";
|
||||
}
|
||||
}
|
||||
$out .= "\n -- J. King <jking@jkingweb.ca> ".\DateTimeImmutable::createFromFormat("Y-m-d", $entry['date'], new \DateTimeZone("UTC"))->format("D, d M Y")." 00:00:00 +0000\n\n";
|
||||
$out .= "\n -- $authorName <$authorMail> ".\DateTimeImmutable::createFromFormat("Y-m-d", $entry['date'], new \DateTimeZone("UTC"))->format("D, d M Y")." 00:00:00 +0000\n\n";
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Produces a Debian "source control" file from various bits of data
|
||||
*
|
||||
* As with a Debian changelog, the output is of a very exacting format,
|
||||
* and this function should be modified with care.
|
||||
*
|
||||
* @param string $dir The path to Debian-specific files, with trailing slash
|
||||
* @param string $version The Debian version string, in the format x.y.z-a
|
||||
* @param array $tarballs An array of paths to the "orig" and "debian" tarball files
|
||||
*/
|
||||
protected function generateDebianSourceControl(string $dir, string $version, array $tarballs): string {
|
||||
// read in control file
|
||||
if (!$control = @file_get_contents($dir."control")) {
|
||||
|
|
16
UPGRADING
16
UPGRADING
|
@ -9,6 +9,22 @@ 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.4 to 0.10.5
|
||||
=============================
|
||||
|
||||
- PHP 7.3 is now required
|
||||
- Web server configuration in the Arch Linux package has been modified to ease
|
||||
the use of alternative PHP interpreters; please review the sample
|
||||
configuration files for changes
|
||||
|
||||
|
||||
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
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse;
|
||||
|
||||
const BASE = __DIR__.DIRECTORY_SEPARATOR;
|
||||
|
@ -13,7 +14,7 @@ require_once BASE."vendor".DIRECTORY_SEPARATOR."autoload.php";
|
|||
ignore_user_abort(true);
|
||||
ini_set("memory_limit", "-1");
|
||||
ini_set("max_execution_time", "0");
|
||||
// FIXME: This is required because various dependencies have yet to adjust to PHP 8.1
|
||||
// NOTE: While running in the wild we don't want to spew deprecation warnings if users are ahead of us in PHP versions
|
||||
error_reporting(\E_ALL & ~\E_DEPRECATED);
|
||||
|
||||
if (\PHP_SAPI === "cli") {
|
||||
|
|
|
@ -18,36 +18,39 @@
|
|||
|
||||
],
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0",
|
||||
"php": ">=7.3",
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-hash": "*",
|
||||
"ext-filter": "*",
|
||||
"ext-dom": "*",
|
||||
"nicolus/picofeed": "^0.1.43",
|
||||
"nicolus/picofeed": "dev-php84",
|
||||
"hosteurope/password-generator": "1.*",
|
||||
"docopt/docopt": "1.*",
|
||||
"docopt/docopt": "dev-master",
|
||||
"jkingweb/druuid": "3.*",
|
||||
"laminas/laminas-diactoros": "2.*",
|
||||
"laminas/laminas-httphandlerrunner": "1.*"
|
||||
"guzzlehttp/psr7": "2.*",
|
||||
"laminas/laminas-xml": "dev-fixup as 1.4.0",
|
||||
"laminas/laminas-httphandlerrunner": "2.*"
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-pcntl": "To respond to signals, particular to reload configuration via SIGHUP"
|
||||
"ext-pcntl": "To respond to signals, particularly to reload configuration via SIGHUP"
|
||||
},
|
||||
"config": {
|
||||
"platform": {
|
||||
"php": "7.1.33"
|
||||
"php": "7.3.33"
|
||||
},
|
||||
"allow-plugins": {
|
||||
"bamarni/composer-bin-plugin": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"post-install-cmd": ["@composer bin all install"],
|
||||
"post-update-cmd": ["@composer bin all update"]
|
||||
"extra": {
|
||||
"bamarni-bin": {
|
||||
"bin-links": false,
|
||||
"forward-command": true
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
@ -59,5 +62,20 @@
|
|||
"JKingWeb\\Arsse\\Test\\": "tests/lib/",
|
||||
"JKingWeb\\Arsse\\TestCase\\": "tests/cases/"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/JKingweb/picoFeed-1/"
|
||||
},
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/JKingweb/laminas-xml/"
|
||||
},
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/mensbeam/docopt.php/"
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
|
|
803
composer.lock
generated
803
composer.lock
generated
File diff suppressed because it is too large
Load diff
5
dist/apache/arsse-fcgi.conf
vendored
Normal file
5
dist/apache/arsse-fcgi.conf
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
ProxyPreserveHost On
|
||||
ProxyFCGISetEnvIf "true" SCRIPT_FILENAME "/usr/share/arsse/arsse.php"
|
||||
ProxyFCGISetEnvIf "-n req('Authorization')" HTTP_AUTHORIZATION "%{req:Authorization}"
|
||||
|
||||
ProxyPass "unix:/var/run/php/arsse.sock|fcgi://localhost/usr/share/arsse/"
|
14
dist/apache/arsse-loc.conf
vendored
14
dist/apache/arsse-loc.conf
vendored
|
@ -1,34 +1,34 @@
|
|||
# Nextcloud News protocol
|
||||
<Location "/index.php/apps/news/api">
|
||||
ProxyPass ${ARSSE_PROXY}
|
||||
Include "/etc/arsse/apache/arsse-fcgi.conf"
|
||||
</Location>
|
||||
|
||||
# Tiny Tiny RSS protocol
|
||||
<Location "/tt-rss/api">
|
||||
ProxyPass ${ARSSE_PROXY}
|
||||
Include "/etc/arsse/apache/arsse-fcgi.conf"
|
||||
</Location>
|
||||
|
||||
# Tiny Tiny RSS feed icons
|
||||
<Location "/tt-rss/feed-icons">
|
||||
ProxyPass ${ARSSE_PROXY}
|
||||
Include "/etc/arsse/apache/arsse-fcgi.conf"
|
||||
</Location>
|
||||
|
||||
# Fever protocol
|
||||
<Location "/fever">
|
||||
ProxyPass ${ARSSE_PROXY}
|
||||
Include "/etc/arsse/apache/arsse-fcgi.conf"
|
||||
</Location>
|
||||
|
||||
# Miniflux protocol
|
||||
<Location "/v1">
|
||||
ProxyPass ${ARSSE_PROXY}
|
||||
Include "/etc/arsse/apache/arsse-fcgi.conf"
|
||||
</Location>
|
||||
|
||||
# Miniflux version number
|
||||
<Location "/version">
|
||||
ProxyPass ${ARSSE_PROXY}
|
||||
Include "/etc/arsse/apache/arsse-fcgi.conf"
|
||||
</Location>
|
||||
|
||||
# Miniflux "health check"
|
||||
<Location "/healthcheck">
|
||||
ProxyPass ${ARSSE_PROXY}
|
||||
Include "/etc/arsse/apache/arsse-fcgi.conf"
|
||||
</Location>
|
||||
|
|
5
dist/apache/arsse.conf
vendored
5
dist/apache/arsse.conf
vendored
|
@ -3,9 +3,4 @@ DocumentRoot "/usr/share/arsse/www"
|
|||
Require all granted
|
||||
</Directory>
|
||||
|
||||
Define ARSSE_PROXY "unix:/var/run/php/arsse.sock|fcgi://localhost/usr/share/arsse/"
|
||||
ProxyPreserveHost On
|
||||
ProxyFCGISetEnvIf "true" SCRIPT_FILENAME "/usr/share/arsse/arsse.php"
|
||||
ProxyFCGISetEnvIf "-n req('Authorization')" HTTP_AUTHORIZATION "%{req:Authorization}"
|
||||
|
||||
Include "/etc/arsse/apache/arsse-loc.conf"
|
||||
|
|
29
dist/arch/PKGBUILD
vendored
29
dist/arch/PKGBUILD
vendored
|
@ -1,12 +1,13 @@
|
|||
# Maintainer: J. King <jking@jkingweb.ca>
|
||||
pkgname="arsse"
|
||||
pkgver=0.9.2
|
||||
pkgver=0.10.4
|
||||
pkgrel=1
|
||||
epoch=
|
||||
pkgdesc="Multi-protocol RSS/Atom newsfeed synchronization server"
|
||||
arch=("any")
|
||||
url="https://thearsse.com/"
|
||||
license=("MIT")
|
||||
conflicts=("arsse-git")
|
||||
depends=()
|
||||
makedepends=()
|
||||
checkdepends=()
|
||||
|
@ -14,44 +15,50 @@ optdepends=("nginx: HTTP server"
|
|||
"apache>=2.4: HTTP server"
|
||||
"percona-server: Alternate database"
|
||||
"postgresql>=10: Alternate database"
|
||||
"php-pgsql>=7.1: PostgreSQL database support")
|
||||
"php-pgsql-interpreter>=7.3: PostgreSQL database support")
|
||||
backup=("etc/webapps/arsse/config.php"
|
||||
"etc/webapps/arsse/systemd-environment"
|
||||
"etc/php/php-fpm.d/arsse.conf"
|
||||
"etc/php-legacy/php-fpm.d/arsse.conf"
|
||||
"etc/webapps/arsse/nginx/example.conf"
|
||||
"etc/webapps/arsse/nginx/arsse.conf"
|
||||
"etc/webapps/arsse/nginx/arsse-loc.conf"
|
||||
"etc/webapps/arsse/nginx/arsse-fcgi.conf"
|
||||
"etc/webapps/arsse/apache/example.conf"
|
||||
"etc/webapps/arsse/apache/arsse.conf"
|
||||
"etc/webapps/arsse/apache/arsse-fcgi.conf"
|
||||
"etc/webapps/arsse/apache/arsse-loc.conf")
|
||||
source=("arsse-0.9.2.tar.gz")
|
||||
source=("arsse-0.10.4.tar.gz")
|
||||
md5sums=("SKIP")
|
||||
|
||||
package() {
|
||||
# define runtime dependencies
|
||||
depends=("php>=7.1" "php-intl>=7.1" "php-sqlite>=7.1" "php-fpm>=7.1")
|
||||
depends=("php-interpreter>=7.3" "php-sqlite-interpreter>=7.3" "php-fpm-interpreter>=7.3")
|
||||
# create most directories necessary for the final package
|
||||
cd "$pkgdir"
|
||||
mkdir -p "usr/share/webapps/arsse" "usr/share/doc/arsse" "usr/share/licenses/arsse" "usr/lib/systemd/system" "usr/lib/sysusers.d" "usr/lib/tmpfiles.d" "etc/php/php-fpm.d" "etc/webapps/arsse"
|
||||
mkdir -p "usr/share/webapps/arsse" "usr/share/doc/arsse" "usr/share/licenses/arsse" "usr/lib/systemd/system" "usr/lib/sysusers.d" "usr/lib/tmpfiles.d" "etc/php/php-fpm.d" "etc/php-legacy/php-fpm.d" "etc/webapps/arsse"
|
||||
# copy requisite files
|
||||
cd "$srcdir/arsse"
|
||||
cp -r lib locale sql vendor www CHANGELOG UPGRADING README.md arsse.php "$pkgdir/usr/share/webapps/arsse"
|
||||
cp -r manual/* "$pkgdir/usr/share/doc/arsse"
|
||||
cp LICENSE AUTHORS "$pkgdir/usr/share/licenses/arsse"
|
||||
cp dist/systemd/* "$pkgdir/usr/lib/systemd/system"
|
||||
cp dist/sysuser.conf "$pkgdir/usr/lib/sysusers.d/arsse.conf"
|
||||
cp dist/tmpfiles.conf "$pkgdir/usr/lib/tmpfiles.d/arsse.conf"
|
||||
cp dist/php-fpm.conf "$pkgdir/etc/php/php-fpm.d/arsse.conf"
|
||||
cp -r dist/man "$pkgdir/usr/share"
|
||||
cp -r dist/nginx dist/apache config.defaults.php "$pkgdir/etc/webapps/arsse"
|
||||
cd "$pkgdir"
|
||||
# copy files requiring special permissions
|
||||
cd "$srcdir/arsse"
|
||||
install -Dm755 dist/arsse "$pkgdir/usr/bin/arsse"
|
||||
install -Dm640 dist/config.php "$pkgdir/etc/webapps/arsse"
|
||||
# patch generic configuration files to use Arch-specific paths and identifiers
|
||||
sed -i -se 's/\/\(etc\|usr\/share\)\/arsse\//\/\1\/webapps\/arsse\//g' "$pkgdir/etc/webapps/arsse/nginx/"* "$pkgdir/etc/webapps/arsse/apache/"* "$pkgdir/usr/lib/tmpfiles.d/arsse.conf" "$pkgdir/usr/lib/systemd/system/"* "$pkgdir/usr/bin/"*
|
||||
sed -i -se 's/\/\(etc\|usr\/share\)\/arsse\//\/\1\/webapps\/arsse\//g' "$pkgdir/etc/webapps/arsse/nginx/"* "$pkgdir/etc/webapps/arsse/apache/"* "$pkgdir/usr/lib/tmpfiles.d/arsse.conf"
|
||||
sed -i -se 's/\/var\/run\/php\//\/run\/php-fpm\//g' "$pkgdir/etc/webapps/arsse/nginx/"* "$pkgdir/etc/webapps/arsse/apache/"* "$pkgdir/etc/php/php-fpm.d/arsse.conf"
|
||||
sed -i -se 's/www-data/http/g' "$pkgdir/etc/php/php-fpm.d/arsse.conf"
|
||||
sed -i -e 's/^WorkingDirectory=.*$/WorkingDirectory=\/usr\/share\/webapps\/arsse/g' -e 's/^ConfigurationDirectory=.*$/ConfigurationDirectory=webapps\/arsse/g' "$pkgdir/usr/lib/systemd/system/arsse-fetch.service"
|
||||
# make a duplicate PHP-FPM pool for php-legacy
|
||||
sed -se 's/php-fpm/php-fpm-legacy/' "$pkgdir/etc/php/php-fpm.d/arsse.conf" > "$pkgdir/etc/php-legacy/php-fpm.d/arsse.conf"
|
||||
# copy Arch-specific versions of files
|
||||
install -Dm755 dist/arch/arsse "$pkgdir/usr/bin/arsse"
|
||||
cp dist/arch/nginx-arsse-fcgi.conf "$pkgdir/etc/webapps/arsse/nginx/arsse-fcgi.conf"
|
||||
cp dist/arch/apache-arsse-fcgi.conf "$pkgdir/etc/webapps/arsse/apache/arsse-fcgi.conf"
|
||||
cp dist/arch/*.service "$pkgdir/usr/lib/systemd/system"
|
||||
cp dist/arch/systemd-environment "$pkgdir/etc/webapps/arsse/systemd-environment"
|
||||
}
|
||||
|
|
31
dist/arch/PKGBUILD-git
vendored
31
dist/arch/PKGBUILD-git
vendored
|
@ -1,6 +1,6 @@
|
|||
# Maintainer: J. King <jking@jkingweb.ca>
|
||||
pkgname="arsse-git"
|
||||
pkgver=0.9.2
|
||||
pkgver=0.10.4
|
||||
pkgrel=1
|
||||
epoch=
|
||||
pkgdesc="Multi-protocol RSS/Atom newsfeed synchronization server, bugfix-testing version"
|
||||
|
@ -9,22 +9,25 @@ url="https://thearsse.com/"
|
|||
license=("MIT")
|
||||
provides=("arsse")
|
||||
conflicts=("arsse")
|
||||
depends=("php>=7.1" "php-intl>=7.1" "php-sqlite>=7.1")
|
||||
makedepends=("composer" "pandoc")
|
||||
depends=("php-interpreter>=7.3" "php-intl-interpreter>=7.3" "php-sqlite-interpreter>=7.3")
|
||||
makedepends=("composer")
|
||||
checkdepends=()
|
||||
optdepends=("nginx: HTTP server"
|
||||
"apache>=2.4: HTTP server"
|
||||
"percona-server: Alternate database"
|
||||
"postgresql>=10: Alternate database"
|
||||
"php-pgsql>=7.1: PostgreSQL database support")
|
||||
"php-pgsql-interpreter>=7.3: PostgreSQL database support")
|
||||
backup=("etc/webapps/arsse/config.php"
|
||||
"etc/webapps/arsse/systemd-environment"
|
||||
"etc/php/php-fpm.d/arsse.conf"
|
||||
"etc/php-legacy/php-fpm.d/arsse.conf"
|
||||
"etc/webapps/arsse/nginx/example.conf"
|
||||
"etc/webapps/arsse/nginx/arsse.conf"
|
||||
"etc/webapps/arsse/nginx/arsse-loc.conf"
|
||||
"etc/webapps/arsse/nginx/arsse-fcgi.conf"
|
||||
"etc/webapps/arsse/apache/example.conf"
|
||||
"etc/webapps/arsse/apache/arsse.conf"
|
||||
"etc/webapps/arsse/apache/arsse-fcgi.conf"
|
||||
"etc/webapps/arsse/apache/arsse-loc.conf")
|
||||
source=("git+https://code.mensbeam.com/MensBeam/arsse/")
|
||||
md5sums=("SKIP")
|
||||
|
@ -37,7 +40,6 @@ pkgver() {
|
|||
build() {
|
||||
cd "$srcdir/arsse"
|
||||
composer install
|
||||
./robo manpage
|
||||
./robo manual
|
||||
composer install --no-dev -o --no-scripts
|
||||
php arsse.php conf save-defaults config.defaults.php
|
||||
|
@ -46,29 +48,32 @@ build() {
|
|||
|
||||
package() {
|
||||
# define runtime dependencies
|
||||
depends=("php>=7.1" "php-intl>=7.1" "php-sqlite>=7.1" "php-fpm>=7.1")
|
||||
depends=("php-interpreter>=7.3" "php-sqlite-interpreter>=7.3" "php-fpm-interpreter>=7.3")
|
||||
# create most directories necessary for the final package
|
||||
cd "$pkgdir"
|
||||
mkdir -p "usr/share/webapps/arsse" "usr/share/doc/arsse" "usr/share/licenses/arsse" "usr/lib/systemd/system" "usr/lib/sysusers.d" "usr/lib/tmpfiles.d" "etc/php/php-fpm.d" "etc/webapps/arsse"
|
||||
mkdir -p "usr/share/webapps/arsse" "usr/share/doc/arsse" "usr/share/licenses/arsse" "usr/lib/systemd/system" "usr/lib/sysusers.d" "usr/lib/tmpfiles.d" "etc/php/php-fpm.d" "etc/php-legacy/php-fpm.d" "etc/webapps/arsse"
|
||||
# copy requisite files
|
||||
cd "$srcdir/arsse"
|
||||
cp -r lib locale sql vendor www CHANGELOG UPGRADING README.md arsse.php "$pkgdir/usr/share/webapps/arsse"
|
||||
cp -r manual/* "$pkgdir/usr/share/doc/arsse"
|
||||
cp LICENSE AUTHORS "$pkgdir/usr/share/licenses/arsse"
|
||||
cp dist/systemd/* "$pkgdir/usr/lib/systemd/system"
|
||||
cp dist/sysuser.conf "$pkgdir/usr/lib/sysusers.d/arsse.conf"
|
||||
cp dist/tmpfiles.conf "$pkgdir/usr/lib/tmpfiles.d/arsse.conf"
|
||||
cp dist/php-fpm.conf "$pkgdir/etc/php/php-fpm.d/arsse.conf"
|
||||
cp -r dist/man "$pkgdir/usr/share"
|
||||
cp -r dist/nginx dist/apache config.defaults.php "$pkgdir/etc/webapps/arsse"
|
||||
cd "$pkgdir"
|
||||
# copy files requiring special permissions
|
||||
cd "$srcdir/arsse"
|
||||
install -Dm755 dist/arsse "$pkgdir/usr/bin/arsse"
|
||||
install -Dm640 dist/config.php "$pkgdir/etc/webapps/arsse"
|
||||
# patch generic configuration files to use Arch-specific paths and identifiers
|
||||
sed -i -se 's/\/\(etc\|usr\/share\)\/arsse\//\/\1\/webapps\/arsse\//g' "$pkgdir/etc/webapps/arsse/nginx/"* "$pkgdir/etc/webapps/arsse/apache/"* "$pkgdir/usr/lib/tmpfiles.d/arsse.conf" "$pkgdir/usr/lib/systemd/system/"* "$pkgdir/usr/bin/"*
|
||||
sed -i -se 's/\/\(etc\|usr\/share\)\/arsse\//\/\1\/webapps\/arsse\//g' "$pkgdir/etc/webapps/arsse/nginx/"* "$pkgdir/etc/webapps/arsse/apache/"* "$pkgdir/usr/lib/tmpfiles.d/arsse.conf"
|
||||
sed -i -se 's/\/var\/run\/php\//\/run\/php-fpm\//g' "$pkgdir/etc/webapps/arsse/nginx/"* "$pkgdir/etc/webapps/arsse/apache/"* "$pkgdir/etc/php/php-fpm.d/arsse.conf"
|
||||
sed -i -se 's/www-data/http/g' "$pkgdir/etc/php/php-fpm.d/arsse.conf"
|
||||
sed -i -e 's/^WorkingDirectory=.*$/WorkingDirectory=\/usr\/share\/webapps\/arsse/g' -e 's/^ConfigurationDirectory=.*$/ConfigurationDirectory=webapps\/arsse/g' "$pkgdir/usr/lib/systemd/system/arsse-fetch.service"
|
||||
# make a duplicate PHP-FPM pool for php-legacy
|
||||
sed -se 's/php-fpm/php-fpm-legacy/' "$pkgdir/etc/php/php-fpm.d/arsse.conf" > "$pkgdir/etc/php-legacy/php-fpm.d/arsse.conf"
|
||||
# copy Arch-specific versions of files
|
||||
install -Dm755 dist/arch/arsse "$pkgdir/usr/bin/arsse"
|
||||
cp dist/arch/nginx-arsse-fcgi.conf "$pkgdir/etc/webapps/arsse/nginx/arsse-fcgi.conf"
|
||||
cp dist/arch/apache-arsse-fcgi.conf "$pkgdir/etc/webapps/arsse/apache/arsse-fcgi.conf"
|
||||
cp dist/arch/*.service "$pkgdir/usr/lib/systemd/system"
|
||||
cp dist/arch/systemd-environment "$pkgdir/etc/webapps/arsse/systemd-environment"
|
||||
}
|
||||
|
|
6
dist/arch/apache-arsse-fcgi.conf
vendored
Normal file
6
dist/arch/apache-arsse-fcgi.conf
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
ProxyPreserveHost On
|
||||
ProxyFCGISetEnvIf "true" SCRIPT_FILENAME "/usr/share/webapps/arsse/arsse.php"
|
||||
ProxyFCGISetEnvIf "-n req('Authorization')" HTTP_AUTHORIZATION "%{req:Authorization}"
|
||||
|
||||
# Modify the below line to begin with "unix:/run/php-fpm-legacy/" if using the php-legacy package
|
||||
ProxyPass "unix:/run/php-fpm/arsse.sock|fcgi://localhost/usr/share/webapps/arsse/"
|
28
dist/arch/arsse
vendored
Normal file
28
dist/arch/arsse
vendored
Normal file
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
readonly default_php="/usr/bin/php"
|
||||
php=""
|
||||
|
||||
check_sudo() {
|
||||
if ! command -v sudo > /dev/null; then
|
||||
printf "The sudo command is not available.\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# allow overriding the php executable
|
||||
if [ -n "${ARSSE_PHP}" ] && command -v "${ARSSE_PHP}" > /dev/null; then
|
||||
php="${ARSSE_PHP}"
|
||||
else
|
||||
php="${default_php}"
|
||||
fi
|
||||
|
||||
if [ "$(whoami)" = "arsse" ]; then
|
||||
"$php" /usr/share/webapps/arsse/arsse "$@"
|
||||
elif [ "${UID}" -eq 0 ]; then
|
||||
runuser -u "arsse" -- "$php" /usr/share/webapps/arsse/arsse "$@"
|
||||
else
|
||||
check_sudo
|
||||
sudo -u "arsse" "$php" /usr/share/webapps/arsse/arsse "$@"
|
||||
fi
|
||||
|
37
dist/arch/arsse-fetch.service
vendored
Normal file
37
dist/arch/arsse-fetch.service
vendored
Normal file
|
@ -0,0 +1,37 @@
|
|||
[Unit]
|
||||
Description=The Arsse newsfeed fetching service
|
||||
Documentation=https://thearsse.com/manual/
|
||||
PartOf=arsse.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
[Service]
|
||||
User=arsse
|
||||
Group=arsse
|
||||
Type=simple
|
||||
WorkingDirectory=/usr/share/webapps/arsse
|
||||
EnvironmentFile=/etc/webapps/arsse/systemd-environment
|
||||
ExecStart=/usr/bin/arsse daemon
|
||||
|
||||
ProtectProc=invisible
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
StateDirectory=arsse
|
||||
ConfigurationDirectory=arsse
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
RestrictSUIDSGID=true
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=arsse
|
||||
Restart=on-failure
|
||||
RestartPreventStatus=
|
||||
|
||||
# These directives can be used for extra security, but are disabled for now for compatibility
|
||||
|
||||
#ReadOnlyPaths=/
|
||||
#ReadWriePaths=/var/lib/arsse
|
||||
#NoExecPaths=/
|
||||
#ExecPaths=/usr/bin/php
|
13
dist/arch/arsse.service
vendored
Normal file
13
dist/arch/arsse.service
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
[Unit]
|
||||
Description=The Arsse newsfeed management service
|
||||
Documentation=https://thearsse.com/manual/
|
||||
Requires=arsse-fetch.service
|
||||
Wants=php-fpm.service php-legacy-fpm.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=true
|
||||
ExecStart=/usr/bin/true
|
16
dist/arch/nginx-arsse-fcgi.conf
vendored
Normal file
16
dist/arch/nginx-arsse-fcgi.conf
vendored
Normal file
|
@ -0,0 +1,16 @@
|
|||
fastcgi_pass_header Authorization; # required if the Arsse is to perform its own HTTP authentication
|
||||
fastcgi_pass_request_body on;
|
||||
fastcgi_pass_request_headers on;
|
||||
fastcgi_intercept_errors off;
|
||||
fastcgi_buffering off;
|
||||
fastcgi_param REQUEST_METHOD $request_method;
|
||||
fastcgi_param CONTENT_TYPE $content_type;
|
||||
fastcgi_param CONTENT_LENGTH $content_length;
|
||||
fastcgi_param REQUEST_URI $uri;
|
||||
fastcgi_param QUERY_STRING $query_string;
|
||||
fastcgi_param HTTPS $https if_not_empty;
|
||||
fastcgi_param REMOTE_USER $remote_user;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/share/webapps/arsse/arsse.php;
|
||||
|
||||
# Modify the below line to begin with "/run/php-fpm-legacy/" if using the php-legacy package
|
||||
fastcgi_pass unix:/run/php-fpm/arsse.sock;
|
1
dist/arch/systemd-environment
vendored
Normal file
1
dist/arch/systemd-environment
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
ARSSE_PHP=/usr/bin/php
|
2
dist/debian/control
vendored
2
dist/debian/control
vendored
|
@ -20,7 +20,7 @@ Description: Multi-protocol RSS/Atom newsfeed synchronization server
|
|||
server.
|
||||
Depends: ${misc:Depends},
|
||||
dbconfig-sqlite3 | dbconfig-pgsql | dbconfig-mysql | dbconfig-no-thanks,
|
||||
php (>= 7.1.0),
|
||||
php (>= 7.3.0),
|
||||
php-cli,
|
||||
php-intl,
|
||||
php-json,
|
||||
|
|
342
dist/man/man1/arsse.1
vendored
Normal file
342
dist/man/man1/arsse.1
vendored
Normal file
|
@ -0,0 +1,342 @@
|
|||
.Dd October 27, 2023
|
||||
.Dt ARSSE 1
|
||||
.Os "The Arsse" 0.10.4
|
||||
.
|
||||
.
|
||||
.Sh NAME
|
||||
.Nm arsse
|
||||
.Nd manage an instance of The Advanced RSS Environment (The Arsse)
|
||||
.
|
||||
.
|
||||
.Sh SYNOPSIS
|
||||
.Nm "arsse user"
|
||||
.Op Nm list
|
||||
.Nm "arsse user add"
|
||||
.Ar username
|
||||
.Op Ar password
|
||||
.Op Fl Fl admin
|
||||
.Nm "arsse user remove"
|
||||
.Ar username
|
||||
.Nm "arsse user show"
|
||||
.Ar username
|
||||
.Nm "arsse user set"
|
||||
.Ar username
|
||||
.Ar property
|
||||
.Ar value
|
||||
.Nm "arsse user unset"
|
||||
.Ar username
|
||||
.Ar property
|
||||
.Nm "arsse user set\-pass"
|
||||
.Ar username
|
||||
.Op Ar password
|
||||
.Op Fl Fl fever
|
||||
.Nm "arsse user unset\-pass"
|
||||
.Ar username
|
||||
.Op Fl Fl fever
|
||||
.Nm "arsse user auth"
|
||||
.Ar username
|
||||
.Op Ar password
|
||||
.Op Fl Fl fever
|
||||
.Nm "arsse token list"
|
||||
.Ar username
|
||||
.Nm "arsse token create"
|
||||
.Ar username
|
||||
.Op Ar label
|
||||
.Nm "arsse token revoke"
|
||||
.Ar username
|
||||
.Op Ar token
|
||||
.Nm "arsse import"
|
||||
.Ar username
|
||||
.Op Ar file
|
||||
.Op Fl f | Fl Fl flat
|
||||
.Op Fl r | Fl Fl replace
|
||||
.Nm "arsse export"
|
||||
.Ar username
|
||||
.Op Ar file
|
||||
.Op Fl f | Fl Fl flat
|
||||
.Nm "arsse daemon"
|
||||
.Op Fl Fl fork Ns = Ns Ar pidfile
|
||||
.Nm "arsse feed refresh\-all"
|
||||
.Nm "arsse feed refresh"
|
||||
.Ar n
|
||||
.Nm "arsse conf save\-defaults"
|
||||
.Nm "arsse"
|
||||
.Fl Fl version | Fl h | Fl Fl help
|
||||
.
|
||||
.
|
||||
.Sh DESCRIPTION
|
||||
.Nm
|
||||
allows a sufficiently privileged user to perform various administrative operations related to The Arsse, including:
|
||||
.Pp
|
||||
.Bl -bullet -compact
|
||||
.It
|
||||
Adding and removing users and managing their metadata
|
||||
.It
|
||||
Managing passwords and authentication tokens
|
||||
.It
|
||||
Importing and exporting OPML newsfeed-lists
|
||||
.El
|
||||
.Pp
|
||||
These are documented in the next section
|
||||
.Sx COMMANDS Ns No .
|
||||
Further, seldom-used commands are documented in the subsequent section
|
||||
.Sx ADDITIONAL COMMANDS Ns No .
|
||||
.
|
||||
.
|
||||
.Sh COMMANDS
|
||||
.
|
||||
.Ss Managing users and metadata
|
||||
.Bl -tag
|
||||
.It Nm "user" Op Nm list
|
||||
Displays a simple list of user names with one entry per line
|
||||
.It Nm "user add" Ar username Oo Ar password Oc Oo Fl Fl admin Oc
|
||||
Adds a new user to the database with the specified username and password.
|
||||
If
|
||||
.Ar password
|
||||
is omitted a random password will be generated and printed.
|
||||
.Pp
|
||||
The
|
||||
.Fl Fl admin
|
||||
flag may be used to mark the user as an administrator.
|
||||
This has no meaning within the context of The Arsse as a whole,
|
||||
but it is used control access to certain features in the Miniflux and Nextcloud News protocols.
|
||||
.It Nm "user remove" Ar username
|
||||
Immediately removes a user from the database.
|
||||
All associated data (folders, subscriptions, etc.) are also removed.
|
||||
.It Nm "user show" Ar username
|
||||
Displays a table of metadata properties and their assigned values for
|
||||
.Ar username Ns No .
|
||||
These properties are primarily used by the Miniflux protocol.
|
||||
Consult the section
|
||||
.Sx USER METADATA
|
||||
for details.
|
||||
.It Nm "user set" Ar username Ar property Ar value
|
||||
Sets a metadata property for a user.
|
||||
These properties are primarily used by the Miniflux protocol.
|
||||
Consult the section
|
||||
.Sx USER METADATA
|
||||
for details.
|
||||
.It Nm "user unset" Ar username Ar property
|
||||
Clears a metadata property for a user.
|
||||
The property is thereafter set to its default value, which is protocol-dependent.
|
||||
.El
|
||||
.
|
||||
.Ss Managing passwords and authentication tokens
|
||||
.Bl -tag
|
||||
.It Nm "user set\-pass" Ar username Oo Ar password Oc Oo Fl Fl fever Oc
|
||||
Changes a user's password to the specified value.
|
||||
If no password is specified, a random password will be generated and printed.
|
||||
.Pp
|
||||
The
|
||||
.Fl Fl fever
|
||||
option sets a user's Fever protocol password instead of their general password.
|
||||
As the Fever protocol requires that passwords be stored insecurely,
|
||||
users do not have Fever passwords by default, and logging in to the Fever protocol is disabled until a suitable password is set.
|
||||
It is highly recommended that a user's Fever password be different from their general password.
|
||||
.It Nm "user unset\-pass" Ar username Oo Fl Fl fever Oc
|
||||
Unsets a user's password, effectively disabling their account.
|
||||
As with password setting, the
|
||||
.Fl Fl fever
|
||||
option may be used to operate on a user's Fever password instead of their general password.
|
||||
.It Nm "user auth" Ar username Ar password Oo Fl Fl fever Oc
|
||||
Tests logging a user in.
|
||||
This only checks that the user's password is correctly recognized;
|
||||
it has no side effects.
|
||||
.Pp
|
||||
The
|
||||
.Fl Fl fever
|
||||
option may be used to test the user's Fever protocol password, if any.
|
||||
.It Nm "token list" Ar username
|
||||
Displays a user's authentication tokens in a simple tabular format.
|
||||
These tokens act as an alternative means of authentication for the Miniflux protocol and may be required by some clients.
|
||||
They do not expire.
|
||||
.It Nm "token create" Ar username Oo Ar label Oc
|
||||
Creates a new random login token and prints it.
|
||||
These tokens act as an alternative means of authentication for the Miniflux protocol and may be required by some clients.
|
||||
An optional
|
||||
.Ar label
|
||||
may be specified to give the token a meaningful name.
|
||||
.It Nm "token revoke" Ar username Oo Ar token Oc
|
||||
Deletes the specified
|
||||
.Ar token
|
||||
from the database.
|
||||
The token itself must be supplied, not its label.
|
||||
If it is omitted all tokens for
|
||||
.Ar username
|
||||
are revoked.
|
||||
.El
|
||||
.
|
||||
.Ss Importing and exporting data
|
||||
.Bl -tag
|
||||
.It Nm "import" Ar username Oo Ar file Oc Oo Fl r | Fl Fl replace Oc Oo Fl f | Fl Fl flat Oc
|
||||
Imports the newsfeeds, folders, and tags found in the OPML formatted
|
||||
.Ar file
|
||||
into the account of the specified user.
|
||||
If no file is specified, data is instead read from standard input.
|
||||
Import operations are atomic:
|
||||
if any of the newsfeeds listed in the input cannot be retrieved, the entire import operation will fail.
|
||||
.Pp
|
||||
The
|
||||
.Fl Fl replace
|
||||
(or
|
||||
.Fl r Ns
|
||||
) option interprets the OPML file as the list of
|
||||
.Em all
|
||||
desired newsfeeds, folders and tags, performing any deletion or moving of existing entries which do not appear in the flle.
|
||||
If this option is not specified, the file is assumed to list desired
|
||||
.Em additions only Ns No .
|
||||
.Pp
|
||||
The
|
||||
.Fl Fl flat
|
||||
(or
|
||||
.Fl f Ns
|
||||
) option can be used to ignore any folder structures in the file, importing any newsfeeds directly into the root folder.
|
||||
Combining this with the
|
||||
.Fl Fl replace
|
||||
option is possible.
|
||||
.It Nm "export" Ar username Oo Ar file Oc Oo Fl f | Fl Fl flat Oc
|
||||
Exports a user's newsfeeds, folders, and tags to the OPML file specified by
|
||||
.Ar file Ns
|
||||
, or standard output if no file is specified.
|
||||
Note that due to a limitation of the OPML format, any commas present in tag names will not be retained in the export.
|
||||
.Pp
|
||||
The
|
||||
.Fl Fl flat
|
||||
(or
|
||||
.Fl f Ns
|
||||
) option can be used to omit folders from the export.
|
||||
Some OPML implementations may not support folders, or arbitrary nesting;
|
||||
this option may be used when planning to import into such software.
|
||||
.El
|
||||
.
|
||||
.
|
||||
.Sh ADDITIONAL COMMANDS
|
||||
.Bl -tag
|
||||
.It Nm "daemon" Oo Fl Fl fork Ns = Ns Ar pidfile Oc
|
||||
Starts the newsfeed fetching service.
|
||||
Normally this command is only invoked by systemd.
|
||||
.Pp
|
||||
The
|
||||
.Fl Fl fork
|
||||
option executes an "old-style" fork-then-terminate daemon rather than a "new-style" non-terminating daemon.
|
||||
This option should only be employed if using a System V-style init daemon on POSIX systems;
|
||||
normally systemd is used. When using this option the daemon will write its process identifier to
|
||||
.Ar pidfile
|
||||
after forking.
|
||||
.It Nm "feed refresh\-all"
|
||||
Performs a one-time fetch of all stale feeds.
|
||||
This command can be used as the basis of a
|
||||
.Nm cron
|
||||
job to keep newsfeeds up-to-date.
|
||||
.It Nm "feed refresh" Ar n
|
||||
Performs a one-time fetch of the feed (not subscription) identified by integer
|
||||
.Ar n Ns No .
|
||||
This is used internally by the fetching service and should not normally be needed.
|
||||
.It Nm "conf save\-defaults" Oo Ar file Oc
|
||||
Prints default configuration parameters to standard output, or to
|
||||
.Ar file
|
||||
if specified.
|
||||
Each parameter is annotated with a short description of its purpose and usage.
|
||||
.El
|
||||
.
|
||||
.
|
||||
.Sh USER METADATA
|
||||
User metadata are primarily used by the Miniflux protocol,
|
||||
and most properties have identical or similar names to those used by Miniflux.
|
||||
Properties may also affect other protocols, or conversely may have no effect even when using the Miniflux protocol;
|
||||
this is noted below when appropriate.
|
||||
.Pp
|
||||
Booleans accept any of the values
|
||||
.Ar true Ns No / Ns Ar false Ns No ,
|
||||
.Ar 1 Ns No / Ns Ar 0 Ns No ,
|
||||
.Ar yes Ns No / Ns Ar no Ns No ,
|
||||
or
|
||||
.Ar on Ns No / Ns Ar off Ns No .
|
||||
.Pp
|
||||
The following metadata properties exist for each user:
|
||||
.Pp
|
||||
.Bl -tag
|
||||
.It Cm num No (integer)
|
||||
The numeric identifier of the user.
|
||||
This is assigned at user creation and is read-only.
|
||||
.It Cm admin No (boolean)
|
||||
Boolean. Whether the user is an administrator.
|
||||
Administrators may manage other users via the Miniflux protocol,
|
||||
and also may trigger feed updates manually via the Nextcloud News protocol.
|
||||
.It Cm lang No (string)
|
||||
The preferred language of the user as a BCP 47 language tag, for example "en-ca".
|
||||
Note that since The Arsse currently only includes English text it is not used by The Arsse itself,
|
||||
but clients may use this metadatum in protocols which expose it.
|
||||
.It Cm tz No (string)
|
||||
The time zone of the user as a Time Zone Database identifier, for example "America/Los_Angeles".
|
||||
.It Cm root_folder_name No (string)
|
||||
The name of the root folder, in protocols which allow it to be renamed.
|
||||
.It Cm sort_asc No (boolean)
|
||||
Whether the user prefers ascending sort order for articles.
|
||||
Descending order is usually the default,
|
||||
but explicitly setting this property false will also make a preference for descending order explicit.
|
||||
.It Cm theme No (string)
|
||||
The user's preferred user-interface theme.
|
||||
This is not used by The Arsse itself, but clients may use this metadatum in protocols which expose it.
|
||||
.It Cm page_size No (integer)
|
||||
The user's preferred page size when listing articles.
|
||||
This is not used by The Arsse itself, but clients may use this metadatum in protocols which expose it.
|
||||
.It Cm shortcuts No (boolean)
|
||||
Whether to enable keyboard shortcuts.
|
||||
This is not used by The Arsse itself, but clients may use this metadatum in protocols which expose it.
|
||||
.It Cm gestures No (boolean)
|
||||
Whether to enable touch gestures.
|
||||
This is not used by The Arsse itself, but clients may use this metadatum in protocols which expose it.
|
||||
.It Cm reading_time No (boolean)
|
||||
Whether to calculate and display the estimated reading time for articles.
|
||||
Currently The Arsse does not calculate reading time, so changing this will likely have no effect.
|
||||
.It Cm stylesheet No (string)
|
||||
A user stylesheet in CSS format.
|
||||
This is not used by The Arsse itself, but clients may use this metadatum in protocols which expose it.
|
||||
.El
|
||||
.
|
||||
.
|
||||
.Sh EXAMPLES
|
||||
.Bl -tag
|
||||
.It Add an administrator to the database with an explicit password:
|
||||
.Bd -literal
|
||||
$ arsse user add \-\-admin alice "Curiouser and curiouser!"
|
||||
.Ed
|
||||
.It Add a regular user to the database with a random password:
|
||||
.Bd -literal
|
||||
$ arsse user add "Bob the Builder"
|
||||
bLS!$_UUZ!iN2i_!^IC6
|
||||
.Ed
|
||||
.It Make Bob the Builder an administrator:
|
||||
.Bd -literal
|
||||
$ arsse user set "Bob the Builder" admin true
|
||||
.Ed
|
||||
.It Disable Alice's account by clearing her password:
|
||||
.Bd -literal
|
||||
$ arsse user unset\-pass alice
|
||||
.Ed
|
||||
.It Move all of Foobar's newsfeeds to the root folder:
|
||||
.Bd -literal
|
||||
$ arsse export foobar \-f | arsse import \-r foobar
|
||||
.Ed
|
||||
.It Fail to log in as Alice:
|
||||
.Bd -literal
|
||||
$ arsse user auth alice "Oh, dear!"
|
||||
Authentication failed
|
||||
$ echo $?
|
||||
1
|
||||
.Ed
|
||||
.El
|
||||
.
|
||||
.
|
||||
.Sh REPORTING BUGS
|
||||
Any bugs found in The Arsse may be reported on the Web via the
|
||||
.Lk https://code.mensbeam.com/MensBeam/arsse "MensBeam code repository"
|
||||
or may be directed to the principal authors by e-mail:
|
||||
.Pp
|
||||
.Bl -bullet -compact
|
||||
.It
|
||||
.Lk https://jkingweb.ca/ "J. King"
|
||||
.It
|
||||
.Lk https://dustinwilson.com/ "Dustin Wilson"
|
||||
.El
|
3
dist/nginx/arsse-fcgi.conf
vendored
3
dist/nginx/arsse-fcgi.conf
vendored
|
@ -10,3 +10,6 @@ fastcgi_param REQUEST_URI $uri;
|
|||
fastcgi_param QUERY_STRING $query_string;
|
||||
fastcgi_param HTTPS $https if_not_empty;
|
||||
fastcgi_param REMOTE_USER $remote_user;
|
||||
|
||||
fastcgi_pass unix:/var/run/php/arsse.sock;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/share/arsse/arsse.php;
|
||||
|
|
4
dist/nginx/arsse.conf
vendored
4
dist/nginx/arsse.conf
vendored
|
@ -2,15 +2,11 @@ root /usr/share/arsse/www;
|
|||
|
||||
location @arsse {
|
||||
# HTTP authentication may be enabled for this location, though this may impact some features
|
||||
fastcgi_pass unix:/var/run/php/arsse.sock;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/share/arsse/arsse.php;
|
||||
include /etc/arsse/nginx/arsse-fcgi.conf;
|
||||
}
|
||||
|
||||
location @arsse_public {
|
||||
# HTTP authentication should not be enabled for this location
|
||||
fastcgi_pass unix:/var/run/php/arsse.sock;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/share/arsse/arsse.php;
|
||||
include /etc/arsse/nginx/arsse-fcgi.conf;
|
||||
}
|
||||
|
||||
|
|
1
dist/tmpfiles.conf
vendored
1
dist/tmpfiles.conf
vendored
|
@ -2,3 +2,4 @@ z /usr/bin/arsse 0755 root arsse - -
|
|||
z /etc/arsse/config.php 0640 root arsse - -
|
||||
L /usr/share/arsse/config.php - root arsse - /etc/arsse/config.php
|
||||
d /var/lib/arsse 0750 arsse arsse - -
|
||||
L /usr/share/arsse/arsse - root arsse - /usr/share/arsse/arsse.php
|
|
@ -35,6 +35,23 @@ LoadModule proxy_fcgi_module modules/mod_proxy_fcgi.so
|
|||
|
||||
No additional set-up is required for Nginx.
|
||||
|
||||
# Using an alternative PHP interpreter
|
||||
|
||||
The above instructions assume you will be using the `php` package as your PHP interpreter. If you wish to use `php-legacy` (which is always one feature version behind, for compatibility) a few configuration tweaks are required. The follwoing commands are a short summary:
|
||||
|
||||
```sh
|
||||
# Enable the necessary PHP extensions; curl is optional but recommended; pdo_sqlite may be used instead of sqlite3, but this is not recommended
|
||||
sudo sed -i -e 's/^;\(extension=\(curl\|iconv\|intl\|sqlite3\)\)$/\1/' /etc/php-legacy/php.ini
|
||||
# Modify the system service's environment
|
||||
sudo sed -i -e 's/^ARSSE_PHP=.*/ARSSE_PHP=\/usr\/bin\/php-legacy/' /etc/webapps/arsse/systemd-environment
|
||||
# Modify the PAM environment for the administrative CLI
|
||||
echo "export ARSSE_PHP=/usr/bin/php-legacy" | sudo tee -a /etc/profile.d/arsse >/dev/null
|
||||
# Modify the Nginx and Apache HTTPD configurations
|
||||
sudo sed -i -se 's/\/run\/php-fpm\//\/run\/php-fpm-legacy\//' /etc/webapps/arsse/apache/arsse-fcgi.conf /etc/webapps/arsse/nginx/arsse-fcgi.conf
|
||||
```
|
||||
|
||||
The above procedure can also be applied to use another PHP version from AUR if so desired.
|
||||
|
||||
# Next steps
|
||||
|
||||
If using a database other than SQLite, you will likely want to [set it up](/en/Getting_Started/Database_Setup) before doing anything else.
|
||||
|
|
|
@ -11,7 +11,7 @@ The Arsse has the following requirements:
|
|||
- A Web server such as:
|
||||
- [Nginx](https://nginx.org)
|
||||
- [Apache HTTP server](https://httpd.apache.org) 2.4 or later
|
||||
- PHP 7.1.0 or later with the following extensions:
|
||||
- PHP 7.3.0 or later with the following extensions:
|
||||
- [intl](https://php.net/manual/en/book.intl.php), [json](https://php.net/manual/en/book.json.php), [hash](https://php.net/manual/en/book.hash.php), [filter](https://php.net/manual/en/book.filter.php), and [dom](https://php.net/manual/en/book.dom.php)
|
||||
- [simplexml](https://php.net/manual/en/book.simplexml.php), and [iconv](https://php.net/manual/en/book.iconv.php)
|
||||
- One of:
|
||||
|
|
|
@ -363,7 +363,7 @@ The default value is equal to two megabytes.
|
|||
|---------|---------|
|
||||
| boolean | `true` |
|
||||
|
||||
Whether to allow the possibility of fetching full article contents from an article's source, if a newsfeed only provides excerpts. Whether fetching will actually happen is governed by a per-newsfeed toggle (defaulting to `false`) which currently can only be changed by manually editing the database.
|
||||
Whether to allow the possibility of fetching full article contents from an article's source, if a newsfeed only provides excerpts. Whether fetching will actually happen is governed by a per-newsfeed toggle (defaulting to `false`) which currently can only be changed via the Miniflux protocol.
|
||||
|
||||
### fetchUserAgentString
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
28
docs/theme/arsse/arsse.css
vendored
28
docs/theme/arsse/arsse.css
vendored
File diff suppressed because one or more lines are too long
4
docs/theme/src/arsse.scss
vendored
4
docs/theme/src/arsse.scss
vendored
|
@ -12,6 +12,10 @@
|
|||
|
||||
/* The Arsse overrides */
|
||||
|
||||
.DarkModeToggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'League Gothic';
|
||||
src: url('fonts/leaguegothic.woff2') format('woff2'),
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse;
|
||||
|
||||
abstract class AbstractException extends \Exception {
|
||||
|
@ -120,7 +121,7 @@ abstract class AbstractException extends \Exception {
|
|||
protected $symbol;
|
||||
protected $params;
|
||||
|
||||
public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) {
|
||||
public function __construct(string $msgID = "", $vars = null, ?\Throwable $e = null) {
|
||||
$this->symbol = $msgID;
|
||||
$this->params = $vars ?? [];
|
||||
if ($msgID === "") {
|
||||
|
|
|
@ -4,10 +4,11 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse;
|
||||
|
||||
class Arsse {
|
||||
public const VERSION = "0.10.2";
|
||||
public const VERSION = "0.10.5";
|
||||
public const REQUIRED_EXTENSIONS = [
|
||||
"intl", // as this extension is required to prepare formatted messages, its absence will throw a distinct English-only exception
|
||||
"dom",
|
||||
|
@ -18,15 +19,15 @@ class Arsse {
|
|||
"iconv", // required by PicoFeed only
|
||||
];
|
||||
|
||||
/** @var Factory */
|
||||
/** @var Factory|\Phake\IMock */
|
||||
public static $obj;
|
||||
/** @var Lang */
|
||||
/** @var Lang|\Phake\IMock */
|
||||
public static $lang;
|
||||
/** @var Conf */
|
||||
/** @var Conf|\Phake\IMock */
|
||||
public static $conf;
|
||||
/** @var Database */
|
||||
/** @var Database|\Phake\IMock */
|
||||
public static $db;
|
||||
/** @var User */
|
||||
/** @var User|\Phake\IMock */
|
||||
public static $user;
|
||||
|
||||
/** @codeCoverageIgnore */
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse;
|
||||
|
||||
use JKingWeb\Arsse\REST\Fever\User as Fever;
|
||||
|
@ -71,7 +72,7 @@ USAGE_TEXT;
|
|||
return ($file === "-" ? null : $file) ?? $stdinOrStdout;
|
||||
}
|
||||
|
||||
public function dispatch(array $argv = null): int {
|
||||
public function dispatch(?array $argv = null): int {
|
||||
$argv = $argv ?? $_SERVER['argv'];
|
||||
$argv0 = array_shift($argv);
|
||||
$args = \Docopt::handle($this->usage($argv0), [
|
||||
|
@ -197,7 +198,7 @@ USAGE_TEXT;
|
|||
return 0;
|
||||
}
|
||||
|
||||
protected function userAddOrSetPassword(string $method, string $user, string $password = null, string $oldpass = null): int {
|
||||
protected function userAddOrSetPassword(string $method, string $user, ?string $password = null, ?string $oldpass = null): int {
|
||||
$passwd = Arsse::$user->$method(...array_slice(func_get_args(), 1));
|
||||
if (is_null($password)) {
|
||||
echo $passwd.\PHP_EOL;
|
||||
|
|
|
@ -5,14 +5,17 @@
|
|||
|
||||
/** Conf class */
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse;
|
||||
|
||||
use AllowDynamicProperties;
|
||||
use JKingWeb\Arsse\Misc\ValueInfo as Value;
|
||||
|
||||
/** Class for loading, saving, and querying configuration
|
||||
*
|
||||
* The Conf class serves both as a means of importing and querying configuration information, as well as a source for default parameters when a configuration file does not specify a value.
|
||||
* All public properties are configuration parameters that may be set by the server administrator. */
|
||||
#[AllowDynamicProperties]
|
||||
class Conf {
|
||||
/** @var string Default language to use for logging and errors */
|
||||
public $lang = "en";
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Conf;
|
||||
|
||||
class Exception extends \JKingWeb\Arsse\AbstractException {
|
||||
|
|
28
lib/Context/AbstractContext.php
Normal file
28
lib/Context/AbstractContext.php
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?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]);
|
||||
}
|
||||
}
|
||||
}
|
36
lib/Context/BooleanMembers.php
Normal file
36
lib/Context/BooleanMembers.php
Normal file
|
@ -0,0 +1,36 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
|
@ -4,18 +4,15 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
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 +27,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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,55 +4,21 @@
|
|||
* 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;
|
||||
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 +28,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
Normal file
262
lib/Context/ExclusionMembers.php
Normal file
|
@ -0,0 +1,262 @@
|
|||
<?php
|
||||
/** @license MIT
|
||||
* Copyright 2017 J. King, Dustin Wilson et al.
|
||||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Context;
|
||||
|
||||
use JKingWeb\Arsse\Misc\ValueInfo;
|
||||
use JKingWeb\Arsse\Misc\Date;
|
||||
|
||||
trait ExclusionMembers {
|
||||
public $folder = null;
|
||||
public $folders = [];
|
||||
public $folderShallow = null;
|
||||
public $foldersShallow = [];
|
||||
public $tag = null;
|
||||
public $tags = [];
|
||||
public $tagName = null;
|
||||
public $tagNames = [];
|
||||
public $subscription = null;
|
||||
public $subscriptions = [];
|
||||
public $edition = null;
|
||||
public $editions = [];
|
||||
public $article = null;
|
||||
public $articles = [];
|
||||
public $label = null;
|
||||
public $labels = [];
|
||||
public $labelName = null;
|
||||
public $labelNames = [];
|
||||
public $annotationTerms = [];
|
||||
public $searchTerms = [];
|
||||
public $titleTerms = [];
|
||||
public $authorTerms = [];
|
||||
public $articleRange = [null, null];
|
||||
public $editionRange = [null, null];
|
||||
public $modifiedRange = [null, null];
|
||||
public $modifiedRanges = [];
|
||||
public $markedRange = [null, null];
|
||||
public $markedRanges = [];
|
||||
|
||||
protected function cleanIdArray(array $spec, bool $allowZero = false): array {
|
||||
$spec = array_values($spec);
|
||||
for ($a = 0; $a < sizeof($spec); $a++) {
|
||||
if (ValueInfo::id($spec[$a], $allowZero)) {
|
||||
$spec[$a] = (int) $spec[$a];
|
||||
} else {
|
||||
$spec[$a] = null;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique(array_filter($spec, function($v) {
|
||||
return !is_null($v);
|
||||
})));
|
||||
}
|
||||
|
||||
protected function cleanStringArray(array $spec): array {
|
||||
$spec = array_values($spec);
|
||||
$stop = sizeof($spec);
|
||||
for ($a = 0; $a < $stop; $a++) {
|
||||
if (strlen($str = ValueInfo::normalize($spec[$a], ValueInfo::T_STRING | ValueInfo::M_DROP) ?? "")) {
|
||||
$spec[$a] = $str;
|
||||
} else {
|
||||
unset($spec[$a]);
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($spec));
|
||||
}
|
||||
|
||||
protected function cleanDateRangeArray(array $spec): array {
|
||||
$spec = array_values($spec);
|
||||
$stop = sizeof($spec);
|
||||
for ($a = 0; $a < $stop; $a++) {
|
||||
if (!is_array($spec[$a]) || sizeof($spec[$a]) !== 2) {
|
||||
unset($spec[$a]);
|
||||
} else {
|
||||
$spec[$a] = ValueInfo::normalize($spec[$a], ValueInfo::T_DATE | ValueInfo::M_ARRAY | ValueInfo::M_DROP);
|
||||
if ($spec[$a] === [null, null]) {
|
||||
unset($spec[$a]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($spec, \SORT_REGULAR));
|
||||
}
|
||||
|
||||
public function folder(?int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function folders(?array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanIdArray($spec, true);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function folderShallow(?int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function foldersShallow(?array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanIdArray($spec, true);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function tag(?int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function tags(?array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanIdArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function tagName(?string $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function tagNames(?array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanStringArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function subscription(?int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function subscriptions(?array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanIdArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function edition(?int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function article(?int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function editions(?array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanIdArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function articles(?array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanIdArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function label(?int $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function labels(?array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanIdArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function labelName(?string $spec = null) {
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function labelNames(?array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanStringArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function annotationTerms(?array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanStringArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function searchTerms(?array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanStringArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function titleTerms(?array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanStringArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function authorTerms(?array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanStringArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function articleRange(?int $start = null, ?int $end = null) {
|
||||
if ($start === null && $end === null) {
|
||||
$spec = null;
|
||||
} else {
|
||||
$spec = [$start, $end];
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function editionRange(?int $start = null, ?int $end = null) {
|
||||
if ($start === null && $end === null) {
|
||||
$spec = null;
|
||||
} else {
|
||||
$spec = [$start, $end];
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function modifiedRange($start = null, $end = null) {
|
||||
if ($start === null && $end === null) {
|
||||
$spec = null;
|
||||
} else {
|
||||
$spec = [Date::normalize($start), Date::normalize($end)];
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function modifiedRanges(?array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanDateRangeArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function markedRange($start = null, $end = null) {
|
||||
if ($start === null && $end === null) {
|
||||
$spec = null;
|
||||
} else {
|
||||
$spec = [Date::normalize($start), Date::normalize($end)];
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
|
||||
public function markedRanges(?array $spec = null) {
|
||||
if (isset($spec)) {
|
||||
$spec = $this->cleanDateRangeArray($spec);
|
||||
}
|
||||
return $this->act(__FUNCTION__, func_num_args(), $spec);
|
||||
}
|
||||
}
|
21
lib/Context/RootContext.php
Normal file
21
lib/Context/RootContext.php
Normal file
|
@ -0,0 +1,21 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
51
lib/Context/UnionContext.php
Normal file
51
lib/Context/UnionContext.php
Normal file
|
@ -0,0 +1,51 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
652
lib/Database.php
652
lib/Database.php
|
@ -4,13 +4,17 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse;
|
||||
|
||||
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 +45,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 */
|
||||
|
@ -81,11 +86,16 @@ class Database {
|
|||
|
||||
/** Returns the bare name of the calling context's calling method, when __FUNCTION__ is not appropriate */
|
||||
protected function caller(): string {
|
||||
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4);
|
||||
if ($trace[2]['function'] === "articleQuery") {
|
||||
return $trace[3]['function'];
|
||||
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10);
|
||||
$out = "";
|
||||
foreach ($trace as $step) {
|
||||
if (($step['class'] ?? "") === __CLASS__) {
|
||||
$out = $step['function'];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $trace[2]['function'];
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Returns the current (actual) schema version of the database; compared against self::SCHEMA_VERSION to know when an upgrade is required */
|
||||
|
@ -275,6 +285,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 +342,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 +364,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]);
|
||||
|
@ -389,7 +413,7 @@ class Database {
|
|||
* @param string $user The user who owns the session to be destroyed
|
||||
* @param string|null $id The identifier of the session to destroy
|
||||
*/
|
||||
public function sessionDestroy(string $user, string $id = null): bool {
|
||||
public function sessionDestroy(string $user, ?string $id = null): bool {
|
||||
if (is_null($id)) {
|
||||
// delete all sessions and report success unconditionally if no identifier was specified
|
||||
$this->db->prepare("DELETE FROM arsse_sessions where \"user\" = ?", "str")->run($user);
|
||||
|
@ -443,7 +467,7 @@ class Database {
|
|||
* @param \DateTimeInterface|null $expires An optional expiry date and time for the token
|
||||
* @param string $data Application-specific data associated with a token
|
||||
*/
|
||||
public function tokenCreate(string $user, string $class, string $id = null, ?\DateTimeInterface $expires = null, string $data = null): string {
|
||||
public function tokenCreate(string $user, string $class, ?string $id = null, ?\DateTimeInterface $expires = null, ?string $data = null): string {
|
||||
if (!$this->userExists($user)) {
|
||||
throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
|
||||
}
|
||||
|
@ -529,22 +553,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());
|
||||
|
@ -652,7 +681,7 @@ class Database {
|
|||
}
|
||||
|
||||
/** Ensures an operation to rename and/or move a folder does not result in a conflict or circular dependence, and raises an exception otherwise */
|
||||
protected function folderValidateMove(string $user, $id = null, $parent = null, string $name = null): ?int {
|
||||
protected function folderValidateMove(string $user, $id = null, $parent = null, ?string $name = null): ?int {
|
||||
$errData = ["action" => $this->caller(), "field" => "parent", 'id' => $parent];
|
||||
if (!$id) {
|
||||
// the root cannot be moved
|
||||
|
@ -679,14 +708,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,
|
||||
|
@ -787,13 +816,19 @@ class Database {
|
|||
* @param boolean $recursive Whether to list subscriptions of descendent folders as well as the selected folder
|
||||
* @param integer|null $id The numeric identifier of a particular subscription; used internally by subscriptionPropertiesGet
|
||||
*/
|
||||
public function subscriptionList(string $user, $folder = null, bool $recursive = true, int $id = null): Db\Result {
|
||||
public function subscriptionList(string $user, $folder = null, bool $recursive = true, ?int $id = null): Db\Result {
|
||||
// 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 +841,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 +858,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 +890,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();
|
||||
|
@ -1017,7 +1056,7 @@ class Database {
|
|||
}
|
||||
|
||||
/** Returns the time at which any of a user's subscriptions (or a specific subscription) was last refreshed, as a DateTimeImmutable object */
|
||||
public function subscriptionRefreshed(string $user, int $id = null): ?\DateTimeImmutable {
|
||||
public function subscriptionRefreshed(string $user, ?int $id = null): ?\DateTimeImmutable {
|
||||
$q = new Query("SELECT max(arsse_feeds.updated) from arsse_feeds join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id");
|
||||
$q->setWhere("arsse_subscriptions.owner = ?", "str", $user);
|
||||
if ($id) {
|
||||
|
@ -1302,12 +1341,12 @@ class Database {
|
|||
"UPDATE arsse_feeds SET title = ?, source = ?, updated = CURRENT_TIMESTAMP, modified = ?, etag = ?, err_count = 0, err_msg = '', next_fetch = ?, size = ?, icon = ? WHERE id = ?",
|
||||
["str", "str", "datetime", "strict str", "datetime", "int", "int", "int"]
|
||||
)->run(
|
||||
$feed->data->title,
|
||||
$feed->data->siteUrl,
|
||||
$feed->title,
|
||||
$feed->siteUrl,
|
||||
$feed->lastModified,
|
||||
$feed->resource->getEtag(),
|
||||
$feed->etag,
|
||||
$feed->nextFetch,
|
||||
sizeof($feed->data->items),
|
||||
sizeof($feed->items),
|
||||
$icon,
|
||||
$feedID
|
||||
);
|
||||
|
@ -1449,6 +1488,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 +1508,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 +1526,11 @@ class Database {
|
|||
* If an empty column list is supplied, a count of articles matching the context is queried instead
|
||||
*
|
||||
* @param string $user The user whose articles are to be queried
|
||||
* @param Context $context The search context
|
||||
* @param RootContext $context The search context
|
||||
* @param array $cols The columns to request in the result set
|
||||
*/
|
||||
protected function articleQuery(string $user, Context $context, array $cols = ["id"]): Query {
|
||||
// validate input
|
||||
if ($context->subscription()) {
|
||||
$this->subscriptionValidateId($user, $context->subscription);
|
||||
}
|
||||
if ($context->folder()) {
|
||||
$this->folderValidateId($user, $context->folder);
|
||||
}
|
||||
if ($context->folderShallow()) {
|
||||
$this->folderValidateId($user, $context->folderShallow);
|
||||
}
|
||||
if ($context->edition()) {
|
||||
$this->articleValidateEdition($user, $context->edition);
|
||||
}
|
||||
if ($context->article()) {
|
||||
$this->articleValidateId($user, $context->article);
|
||||
}
|
||||
if ($context->label()) {
|
||||
$this->labelValidateId($user, $context->label, false);
|
||||
}
|
||||
if ($context->labelName()) {
|
||||
$this->labelValidateId($user, $context->labelName, true);
|
||||
}
|
||||
// prepare the output column list; the column definitions are also used later
|
||||
protected function articleQuery(string $user, RootContext $context, array $cols = ["id"]): Query {
|
||||
// prepare the output column list; the column definitions are also used for ordering
|
||||
$colDefs = $this->articleColumns();
|
||||
if (!$cols) {
|
||||
// if no columns are specified return a count; don't borther with sorting
|
||||
|
@ -1534,7 +1554,23 @@ class Database {
|
|||
assert(strlen($outColumns) > 0, new \Exception("No input columns matched whitelist"));
|
||||
// define the basic query, to which we add lots of stuff where necessary
|
||||
$q = new Query(
|
||||
"SELECT
|
||||
"WITH RECURSIVE
|
||||
folders(id,req) as (
|
||||
select 0, 0 union all select f.id, f.id from arsse_folders as f where owner = ? union all select f1.id, req from arsse_folders as f1 join folders on coalesce(parent,0)=folders.id
|
||||
),
|
||||
folders_top(id,top) as (
|
||||
select f.id, f.id from arsse_folders as f where owner = ? and parent is null union all select f.id, top from arsse_folders as f join folders_top as t on parent=t.id
|
||||
),
|
||||
folder_data(id,name,top,top_name) as (
|
||||
select f1.id, f1.name, top, f2.name from arsse_folders as f1 join folders_top as f0 on f1.id = f0.id join arsse_folders as f2 on f2.id = top
|
||||
),
|
||||
labelled(article,label_id,label_name) as (
|
||||
select m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1
|
||||
),
|
||||
tagged(subscription,tag_id,tag_name) as (
|
||||
select m.subscription, t.id, t.name from arsse_tag_members as m join arsse_tags as t on t.id = m.tag where t.owner = ? and m.assigned = 1
|
||||
)
|
||||
select
|
||||
$outColumns
|
||||
from arsse_articles
|
||||
join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed and arsse_subscriptions.owner = ?
|
||||
|
@ -1543,196 +1579,182 @@ class Database {
|
|||
left join arsse_marks on arsse_marks.subscription = arsse_subscriptions.id and arsse_marks.article = arsse_articles.id
|
||||
left join arsse_enclosures on arsse_enclosures.article = arsse_articles.id
|
||||
join (
|
||||
SELECT article, max(id) as edition from arsse_editions group by article
|
||||
select article, max(id) as edition from arsse_editions group by article
|
||||
) as latest_editions on arsse_articles.id = latest_editions.article
|
||||
left join (
|
||||
SELECT arsse_label_members.article, max(arsse_label_members.modified) as modified, sum(arsse_label_members.assigned) as assigned from arsse_label_members join arsse_labels on arsse_labels.id = arsse_label_members.label where arsse_labels.owner = ? group by arsse_label_members.article
|
||||
select arsse_label_members.article, max(arsse_label_members.modified) as modified, sum(arsse_label_members.assigned) as assigned from arsse_label_members join arsse_labels on arsse_labels.id = arsse_label_members.label where arsse_labels.owner = ? group by arsse_label_members.article
|
||||
) as label_stats on label_stats.article = arsse_articles.id",
|
||||
["str", "str"],
|
||||
[$user, $user]
|
||||
["str", "str", "str", "str", "str", "str"],
|
||||
[$user, $user, $user, $user, $user, $user]
|
||||
);
|
||||
$q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id", ["str"], [$user]);
|
||||
$q->setCTE("folder_data(id,name,top,top_name)", "SELECT f1.id, f1.name, top, f2.name from arsse_folders as f1 join topmost on f1.id = f_id join arsse_folders as f2 on f2.id = top");
|
||||
$q->setLimit($context->limit, $context->offset);
|
||||
if ($context instanceof UnionContext) {
|
||||
// if the context is a union context, we compute each context in turn
|
||||
$q->setWhereRestrictive(false);
|
||||
foreach ($context as $c) {
|
||||
$q->setWhereGroup($this->articleFilter($c));
|
||||
}
|
||||
} else {
|
||||
// if the context is not a union, first validate input to catch 404s and the like
|
||||
if ($context->subscription()) {
|
||||
$this->subscriptionValidateId($user, $context->subscription);
|
||||
}
|
||||
if ($context->folder()) {
|
||||
$this->folderValidateId($user, $context->folder);
|
||||
}
|
||||
if ($context->folderShallow()) {
|
||||
$this->folderValidateId($user, $context->folderShallow);
|
||||
}
|
||||
if ($context->edition()) {
|
||||
$this->articleValidateEdition($user, $context->edition);
|
||||
}
|
||||
if ($context->article()) {
|
||||
$this->articleValidateId($user, $context->article);
|
||||
}
|
||||
if ($context->label()) {
|
||||
$this->labelValidateId($user, $context->label, false);
|
||||
}
|
||||
if ($context->labelName()) {
|
||||
$this->labelValidateId($user, $context->labelName, true);
|
||||
}
|
||||
// ensure any used array-type context options contain at least one member
|
||||
foreach ([
|
||||
"articles",
|
||||
"editions",
|
||||
"subscriptions",
|
||||
"folders",
|
||||
"foldersShallow",
|
||||
"labels",
|
||||
"labelNames",
|
||||
"tags",
|
||||
"tagNames",
|
||||
"searchTerms",
|
||||
"titleTerms",
|
||||
"authorTerms",
|
||||
"annotationTerms",
|
||||
"modifiedRanges",
|
||||
"markedRanges",
|
||||
] as $m) {
|
||||
if ($context->$m() && !$context->$m) {
|
||||
throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]);
|
||||
}
|
||||
}
|
||||
// next compute the context, supplying the query to manipulate directly
|
||||
$this->articleFilter($context, $q);
|
||||
}
|
||||
// return the query
|
||||
return $q;
|
||||
}
|
||||
|
||||
protected function articleFilter(Context $context, ?QueryFilter $q = null) {
|
||||
$q = $q ?? new QueryFilter;
|
||||
$colDefs = $this->articleColumns();
|
||||
// handle the simple context options
|
||||
$options = [
|
||||
// each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an option to pair with for BETWEEN evaluation
|
||||
"edition" => ["edition", "=", "int", ""],
|
||||
"editions" => ["edition", "in", "int", ""],
|
||||
"article" => ["id", "=", "int", ""],
|
||||
"articles" => ["id", "in", "int", ""],
|
||||
"oldestArticle" => ["id", ">=", "int", "latestArticle"],
|
||||
"latestArticle" => ["id", "<=", "int", "oldestArticle"],
|
||||
"oldestEdition" => ["edition", ">=", "int", "latestEdition"],
|
||||
"latestEdition" => ["edition", "<=", "int", "oldestEdition"],
|
||||
"modifiedSince" => ["modified_date", ">=", "datetime", "notModifiedSince"],
|
||||
"notModifiedSince" => ["modified_date", "<=", "datetime", "modifiedSince"],
|
||||
"markedSince" => ["marked_date", ">=", "datetime", "notMarkedSince"],
|
||||
"notMarkedSince" => ["marked_date", "<=", "datetime", "markedSince"],
|
||||
"folderShallow" => ["folder", "=", "int", ""],
|
||||
"foldersShallow" => ["folder", "in", "int", ""],
|
||||
"subscription" => ["subscription", "=", "int", ""],
|
||||
"subscriptions" => ["subscription", "in", "int", ""],
|
||||
"unread" => ["unread", "=", "bool", ""],
|
||||
"starred" => ["starred", "=", "bool", ""],
|
||||
"hidden" => ["hidden", "=", "bool", ""],
|
||||
// each context array consists of a column identifier (see $colDefs above), a comparison operator, and a data type; the "between" operator has special handling
|
||||
"edition" => ["edition", "=", "int"],
|
||||
"editions" => ["edition", "in", "int"],
|
||||
"article" => ["id", "=", "int"],
|
||||
"articles" => ["id", "in", "int"],
|
||||
"articleRange" => ["id", "between", "int"],
|
||||
"editionRange" => ["edition", "between", "int"],
|
||||
"modifiedRange" => ["modified_date", "between", "datetime"],
|
||||
"markedRange" => ["marked_date", "between", "datetime"],
|
||||
"folderShallow" => ["folder", "=", "int"],
|
||||
"foldersShallow" => ["folder", "in", "int"],
|
||||
"subscription" => ["subscription", "=", "int"],
|
||||
"subscriptions" => ["subscription", "in", "int"],
|
||||
"unread" => ["unread", "=", "bool"],
|
||||
"starred" => ["starred", "=", "bool"],
|
||||
"hidden" => ["hidden", "=", "bool"],
|
||||
"labelled" => ["labelled", "=", "bool"],
|
||||
"annotated" => ["annotated", "=", "bool"],
|
||||
];
|
||||
foreach ($options as $m => [$col, $op, $type, $pair]) {
|
||||
if (!$context->$m()) {
|
||||
// context is not being used
|
||||
continue;
|
||||
} elseif (is_array($context->$m)) {
|
||||
// context option is an array of values
|
||||
if (!$context->$m) {
|
||||
throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element
|
||||
}
|
||||
[$clause, $types, $values] = $this->generateIn($context->$m, $type);
|
||||
$q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values);
|
||||
} elseif ($pair && $context->$pair()) {
|
||||
// option is paired with another which is also being used
|
||||
if ($op === ">=") {
|
||||
$q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->$m, $context->$pair]);
|
||||
foreach ($options as $m => [$col, $op, $type]) {
|
||||
if ($context->$m()) {
|
||||
if ($op === "between") {
|
||||
// option is a range
|
||||
if ($context->$m[0] === null) {
|
||||
// range is open at the low end
|
||||
$q->setWhere("{$colDefs[$col]} <= ?", $type, $context->$m[1]);
|
||||
} elseif ($context->$m[1] === null) {
|
||||
// range is open at the high end
|
||||
$q->setWhere("{$colDefs[$col]} >= ?", $type, $context->$m[0]);
|
||||
} else {
|
||||
// range is bounded in both directions
|
||||
$q->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $context->$m);
|
||||
}
|
||||
} elseif (is_array($context->$m)) {
|
||||
// context option is an array of values
|
||||
[$clause, $types, $values] = $this->generateIn($context->$m, $type);
|
||||
$q->setWhere("{$colDefs[$col]} $op ($clause)", $types, $values);
|
||||
} else {
|
||||
// option has already been paired
|
||||
continue;
|
||||
$q->setWhere("{$colDefs[$col]} $op ?", $type, $context->$m);
|
||||
}
|
||||
}
|
||||
// handle the exclusionary version
|
||||
if (method_exists($context->not, $m) && $context->not->$m()) {
|
||||
if ($op === "between") {
|
||||
// option is a range
|
||||
if ($context->not->$m[0] === null) {
|
||||
// range is open at the low end
|
||||
$q->setWhereNot("{$colDefs[$col]} <= ?", $type, $context->not->$m[1]);
|
||||
} elseif ($context->not->$m[1] === null) {
|
||||
// range is open at the high end
|
||||
$q->setWhereNot("{$colDefs[$col]} >= ?", $type, $context->not->$m[0]);
|
||||
} else {
|
||||
// range is bounded in both directions
|
||||
$q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $context->not->$m);
|
||||
}
|
||||
} elseif (is_array($context->not->$m)) {
|
||||
if (!$context->not->$m) {
|
||||
// for exclusions we don't care if the array is empty
|
||||
continue;
|
||||
}
|
||||
[$clause, $types, $values] = $this->generateIn($context->not->$m, $type);
|
||||
$q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values);
|
||||
} else {
|
||||
$q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m);
|
||||
}
|
||||
} else {
|
||||
$q->setWhere("{$colDefs[$col]} $op ?", $type, $context->$m);
|
||||
}
|
||||
}
|
||||
// further handle exclusionary options if specified
|
||||
foreach ($options as $m => [$col, $op, $type, $pair]) {
|
||||
if (!method_exists($context->not, $m) || !$context->not->$m()) {
|
||||
// context option is not being used
|
||||
continue;
|
||||
} elseif (is_array($context->not->$m)) {
|
||||
if (!$context->not->$m) {
|
||||
// for exclusions we don't care if the array is empty
|
||||
continue;
|
||||
}
|
||||
[$clause, $types, $values] = $this->generateIn($context->not->$m, $type);
|
||||
$q->setWhereNot("{$colDefs[$col]} $op ($clause)", $types, $values);
|
||||
} elseif ($pair && $context->not->$pair()) {
|
||||
// option is paired with another which is also being used
|
||||
if ($op === ">=") {
|
||||
$q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], [$context->not->$m, $context->not->$pair]);
|
||||
} else {
|
||||
// option has already been paired
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
$q->setWhereNot("{$colDefs[$col]} $op ?", $type, $context->not->$m);
|
||||
}
|
||||
}
|
||||
// handle labels and tags
|
||||
// handle folder trees, labels, and tags
|
||||
$options = [
|
||||
'label' => [
|
||||
'match_col' => "arsse_articles.id",
|
||||
'cte_name' => "labelled",
|
||||
'cte_cols' => ["article", "label_id", "label_name"],
|
||||
'cte_body' => "SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1",
|
||||
'cte_types' => ["str"],
|
||||
'cte_values' => [$user],
|
||||
'options' => [
|
||||
'label' => ['use_name' => false, 'multi' => false],
|
||||
'labels' => ['use_name' => false, 'multi' => true],
|
||||
'labelName' => ['use_name' => true, 'multi' => false],
|
||||
'labelNames' => ['use_name' => true, 'multi' => true],
|
||||
],
|
||||
],
|
||||
'tag' => [
|
||||
'match_col' => "arsse_subscriptions.id",
|
||||
'cte_name' => "tagged",
|
||||
'cte_cols' => ["subscription", "tag_id", "tag_name"],
|
||||
'cte_body' => "SELECT m.subscription, t.id, t.name from arsse_tag_members as m join arsse_tags as t on t.id = m.tag where t.owner = ? and m.assigned = 1",
|
||||
'cte_types' => ["str"],
|
||||
'cte_values' => [$user],
|
||||
'options' => [
|
||||
'tag' => ['use_name' => false, 'multi' => false],
|
||||
'tags' => ['use_name' => false, 'multi' => true],
|
||||
'tagName' => ['use_name' => true, 'multi' => false],
|
||||
'tagNames' => ['use_name' => true, 'multi' => true],
|
||||
],
|
||||
],
|
||||
// each context array consists of a common table expression to select from, the column to match in the main join, the column to match in the CTE, the column to select in the CTE, an operator, and a type for the match in the CTE
|
||||
'folder' => ["folders", "folder", "folders.id", "req", "=", "int"],
|
||||
'folders' => ["folders", "folder", "folders.id", "req", "in", "int"],
|
||||
'label' => ["labelled", "id", "labelled.article", "label_id", "=", "int"],
|
||||
'labels' => ["labelled", "id", "labelled.article", "label_id", "in", "int"],
|
||||
'labelName' => ["labelled", "id", "labelled.article", "label_name", "=", "str"],
|
||||
'labelNames' => ["labelled", "id", "labelled.article", "label_name", "in", "str"],
|
||||
'tag' => ["tagged", "subscription", "tagged.subscription", "tag_id", "=", "int"],
|
||||
'tags' => ["tagged", "subscription", "tagged.subscription", "tag_id", "in", "int"],
|
||||
'tagName' => ["tagged", "subscription", "tagged.subscription", "tag_name", "=", "str"],
|
||||
'tagNames' => ["tagged", "subscription", "tagged.subscription", "tag_name", "in", "str"],
|
||||
];
|
||||
foreach ($options as $opt) {
|
||||
$seen = false;
|
||||
$match = $opt['match_col'];
|
||||
$table = $opt['cte_name'];
|
||||
foreach ($opt['options'] as $m => $props) {
|
||||
$named = $props['use_name'];
|
||||
$multi = $props['multi'];
|
||||
$selection = $opt['cte_cols'][0];
|
||||
$col = $opt['cte_cols'][$named ? 2 : 1];
|
||||
if ($context->$m()) {
|
||||
$seen = true;
|
||||
if (!$context->$m) {
|
||||
throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element
|
||||
}
|
||||
if ($multi) {
|
||||
[$test, $types, $values] = $this->generateIn($context->$m, $named ? "str" : "int");
|
||||
$test = "in ($test)";
|
||||
} 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) {
|
||||
[$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);
|
||||
foreach ($options as $m => [$cte, $outerCol, $selection, $innerCol, $op, $type]) {
|
||||
if ($context->$m()) {
|
||||
if ($op === "in") {
|
||||
[$inClause, $inTypes, $inValues] = $this->generateIn($context->$m, $type);
|
||||
$q->setWhere("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol in($inClause))", $inTypes, $inValues);
|
||||
} else {
|
||||
$q->setWhere("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol = ?)", $type, $context->$m);
|
||||
}
|
||||
}
|
||||
if ($seen) {
|
||||
$spec = $opt['cte_name']."(".implode(",", $opt['cte_cols']).")";
|
||||
$q->setCTE($spec, $opt['cte_body'], $opt['cte_types'], $opt['cte_values']);
|
||||
// handle the exclusionary version
|
||||
if ($context->not->$m()) {
|
||||
if ($op === "in") {
|
||||
if (!$context->not->$m) {
|
||||
// for exclusions we don't care if the array is empty
|
||||
continue;
|
||||
}
|
||||
[$inClause, $inTypes, $inValues] = $this->generateIn($context->not->$m, $type);
|
||||
$q->setWhereNot("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol in($inClause))", $inTypes, $inValues);
|
||||
} else {
|
||||
$q->setWhereNot("{$colDefs[$outerCol]} in (select $selection from $cte where $innerCol = ?)", $type, $context->not->$m);
|
||||
}
|
||||
}
|
||||
}
|
||||
// handle complex context options
|
||||
if ($context->annotated()) {
|
||||
$comp = ($context->annotated) ? "<>" : "=";
|
||||
$q->setWhere("coalesce(arsse_marks.note,'') $comp ''");
|
||||
}
|
||||
if ($context->labelled()) {
|
||||
// any label (true) or no label (false)
|
||||
$op = $context->labelled ? ">" : "=";
|
||||
$q->setWhere("coalesce(label_stats.assigned,0) $op 0");
|
||||
}
|
||||
if ($context->folder()) {
|
||||
// add a common table expression to list the folder and its children so that we select from the entire subtree
|
||||
$q->setCTE("folders(folder)", "SELECT ? union all select id from arsse_folders join folders on coalesce(parent,0) = folder", "int", $context->folder);
|
||||
// limit subscriptions to the listed folders
|
||||
$q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders)");
|
||||
}
|
||||
if ($context->folders()) {
|
||||
[$inClause, $inTypes, $inValues] = $this->generateIn($context->folders, "int");
|
||||
// add a common table expression to list the folders and their children so that we select from the entire subtree
|
||||
$q->setCTE("folders_multi(folder)", "SELECT id as folder from (select id from (select 0 as id union all select id from arsse_folders where owner = ?) as f where id in ($inClause)) as folders_multi union select id from arsse_folders join folders_multi on coalesce(parent,0) = folder", ["str", $inTypes], [$user, $inValues]);
|
||||
// limit subscriptions to the listed folders
|
||||
$q->setWhere("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi)");
|
||||
}
|
||||
if ($context->not->folder()) {
|
||||
// add a common table expression to list the folder and its children so that we exclude from the entire subtree
|
||||
$q->setCTE("folders_excluded(folder)", "SELECT ? union all select id from arsse_folders join folders_excluded on coalesce(parent,0) = folder", "int", $context->not->folder);
|
||||
// excluded any subscriptions in the listed folders
|
||||
$q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_excluded)");
|
||||
}
|
||||
if ($context->not->folders()) {
|
||||
[$inClause, $inTypes, $inValues] = $this->generateIn($context->not->folders, "int");
|
||||
// add a common table expression to list the folders and their children so that we select from the entire subtree
|
||||
$q->setCTE("folders_multi_excluded(folder)", "SELECT id as folder from (select id from (select 0 as id union all select id from arsse_folders where owner = ?) as f where id in ($inClause)) as folders_multi_excluded union select id from arsse_folders join folders_multi_excluded on coalesce(parent,0) = folder", ["str", $inTypes], [$user, $inValues]);
|
||||
// limit subscriptions to the listed folders
|
||||
$q->setWhereNot("coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi_excluded)");
|
||||
}
|
||||
// handle text-matching context options
|
||||
$options = [
|
||||
"titleTerms" => ["title"],
|
||||
|
@ -1741,29 +1763,56 @@ class Database {
|
|||
"annotationTerms" => ["note"],
|
||||
];
|
||||
foreach ($options as $m => $columns) {
|
||||
if (!$context->$m()) {
|
||||
continue;
|
||||
} elseif (!$context->$m) {
|
||||
throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element
|
||||
}
|
||||
$columns = array_map(function($c) use ($colDefs) {
|
||||
assert(isset($colDefs[$c]), new Exception("constantUnknown", $c));
|
||||
return $colDefs[$c];
|
||||
}, $columns);
|
||||
$q->setWhere(...$this->generateSearch($context->$m, $columns));
|
||||
}
|
||||
// further handle exclusionary text-matching context options
|
||||
foreach ($options as $m => $columns) {
|
||||
if (!$context->not->$m() || !$context->not->$m) {
|
||||
continue;
|
||||
if ($context->$m()) {
|
||||
$q->setWhere(...$this->generateSearch($context->$m, $columns));
|
||||
}
|
||||
// handle the exclusionary version
|
||||
if ($context->not->$m() && $context->not->$m) {
|
||||
$q->setWhereNot(...$this->generateSearch($context->not->$m, $columns, true));
|
||||
}
|
||||
}
|
||||
// handle arrays of ranges
|
||||
$options = [
|
||||
'modifiedRanges' => ["modified_date", "datetime"],
|
||||
'markedRanges' => ["marked_date", "datetime"],
|
||||
];
|
||||
foreach ($options as $m => [$col, $type]) {
|
||||
if ($context->$m()) {
|
||||
$subq = (new QueryFilter)->setWhereRestrictive(false);
|
||||
foreach ($context->$m as $r) {
|
||||
if ($r[0] === null) {
|
||||
// range is open at the low end
|
||||
$subq->setWhere("{$colDefs[$col]} <= ?", $type, $r[1]);
|
||||
} elseif ($r[1] === null) {
|
||||
// range is open at the high end
|
||||
$subq->setWhere("{$colDefs[$col]} >= ?", $type, $r[0]);
|
||||
} else {
|
||||
// range is bounded in both directions
|
||||
$subq->setWhere("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $r);
|
||||
}
|
||||
}
|
||||
$q->setWhereGroup($subq);
|
||||
}
|
||||
// handle the exclusionary version
|
||||
if ($context->not->$m() && $context->not->$m) {
|
||||
foreach ($context->not->$m as $r) {
|
||||
if ($r[0] === null) {
|
||||
// range is open at the low end
|
||||
$q->setWhereNot("{$colDefs[$col]} <= ?", $type, $r[1]);
|
||||
} elseif ($r[1] === null) {
|
||||
// range is open at the high end
|
||||
$q->setWhereNot("{$colDefs[$col]} >= ?", $type, $r[0]);
|
||||
} else {
|
||||
// range is bounded in both directions
|
||||
$q->setWhereNot("{$colDefs[$col]} BETWEEN ? AND ?", [$type, $type], $r);
|
||||
}
|
||||
}
|
||||
}
|
||||
$columns = array_map(function($c) use ($colDefs) {
|
||||
assert(isset($colDefs[$c]), new Exception("constantUnknown", $c));
|
||||
return $colDefs[$c];
|
||||
}, $columns);
|
||||
$q->setWhereNot(...$this->generateSearch($context->not->$m, $columns, true));
|
||||
}
|
||||
// return the query
|
||||
return $q;
|
||||
}
|
||||
|
||||
|
@ -1772,11 +1821,11 @@ class Database {
|
|||
* If an empty column list is supplied, a count of articles is returned instead
|
||||
*
|
||||
* @param string $user The user whose articles are to be listed
|
||||
* @param Context $context The search context
|
||||
* @param RootContext $context The search context
|
||||
* @param array $fieldss The columns to return in the result set, any of: id, edition, url, title, author, content, guid, fingerprint, folder, subscription, feed, starred, unread, note, published_date, edited_date, modified_date, marked_date, subscription_title, media_url, media_type
|
||||
* @param array $sort The columns to sort the result by eg. "edition desc" in decreasing order of importance
|
||||
*/
|
||||
public function articleList(string $user, Context $context = null, array $fields = ["id"], array $sort = []): Db\Result {
|
||||
public function articleList(string $user, ?RootContext $context = null, array $fields = ["id"], array $sort = []): Db\Result {
|
||||
// make a base query based on context and output columns
|
||||
$context = $context ?? new Context;
|
||||
$q = $this->articleQuery($user, $context, $fields);
|
||||
|
@ -1818,9 +1867,9 @@ class Database {
|
|||
/** Returns a count of articles which match the given query context
|
||||
*
|
||||
* @param string $user The user whose articles are to be counted
|
||||
* @param Context $context The search context
|
||||
* @param RootContext $context The search context
|
||||
*/
|
||||
public function articleCount(string $user, Context $context = null): int {
|
||||
public function articleCount(string $user, ?RootContext $context = null): int {
|
||||
$context = $context ?? new Context;
|
||||
$q = $this->articleQuery($user, $context, []);
|
||||
return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
|
||||
|
@ -1837,10 +1886,10 @@ class Database {
|
|||
*
|
||||
* @param string $user The user who owns the articles to be modified
|
||||
* @param array $data An associative array of properties to modify. Anything not specified will remain unchanged
|
||||
* @param Context $context The query context to match articles against
|
||||
* @param RootContext $context The query context to match articles against
|
||||
* @param bool $updateTimestamp Whether to also update the timestamp. This should only be false if a mark is changed as a result of an automated action not taken by the user
|
||||
*/
|
||||
public function articleMark(string $user, array $data, Context $context = null, bool $updateTimestamp = true): int {
|
||||
public function articleMark(string $user, array $data, ?RootContext $context = null, bool $updateTimestamp = true): int {
|
||||
$data = [
|
||||
'read' => $data['read'] ?? null,
|
||||
'starred' => $data['starred'] ?? null,
|
||||
|
@ -1865,10 +1914,23 @@ class Database {
|
|||
// marking as read is ignored if the edition is not the latest, but the same is not true of the other two marks
|
||||
$this->db->query("UPDATE arsse_marks set touched = 0 where touched <> 0");
|
||||
// set read marks
|
||||
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
|
||||
$q->setWhere("arsse_marks.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']);
|
||||
$q->pushCTE("target_articles(article,subscription)");
|
||||
$q->setBody("UPDATE arsse_marks set \"read\" = ?, touched = 1 where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", "bool", $data['read']);
|
||||
$subq = $this->articleQuery($user, $context, ["id", "subscription"]);
|
||||
$subq->setWhere("arsse_marks.read <> coalesce(?,arsse_marks.read)", "bool", $data['read']);
|
||||
$q = new Query(
|
||||
"WITH RECURSIVE
|
||||
target_articles(article, subscription) as (
|
||||
{$subq->getQuery()}
|
||||
)
|
||||
update arsse_marks
|
||||
set
|
||||
\"read\" = ?,
|
||||
touched = 1
|
||||
where
|
||||
article in (select article from target_articles)
|
||||
and subscription in (select distinct subscription from target_articles)",
|
||||
[$subq->getTypes(), "bool"],
|
||||
[$subq->getValues(), $data['read']]
|
||||
);
|
||||
$this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
|
||||
// get the articles associated with the requested editions
|
||||
if ($context->edition()) {
|
||||
|
@ -1878,14 +1940,27 @@ class Database {
|
|||
}
|
||||
// set starred, hidden, and/or note marks (unless all requested editions actually do not exist)
|
||||
if ($context->article || $context->articles) {
|
||||
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
|
||||
$q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['hidden']]);
|
||||
$q->pushCTE("target_articles(article,subscription)");
|
||||
$data = array_filter($data, function($v) {
|
||||
$setData = array_filter($data, function($v) {
|
||||
return isset($v);
|
||||
});
|
||||
[$set, $setTypes, $setValues] = $this->generateSet($data, ['starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
|
||||
$q->setBody("UPDATE arsse_marks set touched = 1, $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues);
|
||||
[$set, $setTypes, $setValues] = $this->generateSet($setData, ['starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
|
||||
$subq = $this->articleQuery($user, $context, ["id", "subscription"]);
|
||||
$subq->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['hidden']]);
|
||||
$q = new Query(
|
||||
"WITH RECURSIVE
|
||||
target_articles(article, subscription) as (
|
||||
{$subq->getQuery()}
|
||||
)
|
||||
update arsse_marks
|
||||
set
|
||||
touched = 1,
|
||||
$set
|
||||
where
|
||||
article in (select article from target_articles)
|
||||
and subscription in (select distinct subscription from target_articles)",
|
||||
[$subq->getTypes(), $setTypes],
|
||||
[$subq->getValues(), $setValues]
|
||||
);
|
||||
$this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
|
||||
}
|
||||
// finally set the modification date for all touched marks and return the number of affected marks
|
||||
|
@ -1906,17 +1981,29 @@ class Database {
|
|||
return 0;
|
||||
}
|
||||
}
|
||||
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
|
||||
$q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool", "bool"], [$data['note'], $data['starred'], $data['read'], $data['hidden']]);
|
||||
$q->pushCTE("target_articles(article,subscription)");
|
||||
$data = array_filter($data, function($v) {
|
||||
$setData = array_filter($data, function($v) {
|
||||
return isset($v);
|
||||
});
|
||||
[$set, $setTypes, $setValues] = $this->generateSet($data, ['read' => "bool", 'starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
|
||||
[$set, $setTypes, $setValues] = $this->generateSet($setData, ['read' => "bool", 'starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
|
||||
if ($updateTimestamp) {
|
||||
$set .= ", modified = CURRENT_TIMESTAMP";
|
||||
}
|
||||
$q->setBody("UPDATE arsse_marks set $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues);
|
||||
$subq = $this->articleQuery($user, $context, ["id", "subscription"]);
|
||||
$subq->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool", "bool"], [$data['note'], $data['starred'], $data['read'], $data['hidden']]);
|
||||
$q = new Query(
|
||||
"WITH RECURSIVE
|
||||
target_articles(article, subscription) as (
|
||||
{$subq->getQuery()}
|
||||
)
|
||||
update arsse_marks
|
||||
set
|
||||
$set
|
||||
where
|
||||
article in (select article from target_articles)
|
||||
and subscription in (select distinct subscription from target_articles)",
|
||||
[$subq->getTypes(), $setTypes],
|
||||
[$subq->getValues(), $setValues]
|
||||
);
|
||||
$out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes();
|
||||
}
|
||||
$tr->commit();
|
||||
|
@ -1971,7 +2058,6 @@ class Database {
|
|||
|
||||
/** Deletes from the database articles which are beyond the configured clean-up threshold */
|
||||
public function articleCleanup(): bool {
|
||||
$integer = $this->db->sqlToken("integer");
|
||||
$query = $this->db->prepareArray(
|
||||
"WITH RECURSIVE
|
||||
exempt_articles as (
|
||||
|
@ -1997,8 +2083,8 @@ class Database {
|
|||
left join (
|
||||
select
|
||||
article,
|
||||
sum(cast((starred = 1 and hidden = 0) as $integer)) as starred,
|
||||
sum(cast((\"read\" = 1 or hidden = 1) as $integer)) as \"read\",
|
||||
sum(case when starred = 1 and hidden = 0 then 1 else 0 end) as starred,
|
||||
sum(case when \"read\" = 1 or hidden = 1 then 1 else 0 end) as \"read\",
|
||||
max(arsse_marks.modified) as marked_date
|
||||
from arsse_marks
|
||||
group by article
|
||||
|
@ -2086,7 +2172,7 @@ class Database {
|
|||
}
|
||||
|
||||
/** Returns the numeric identifier of the most recent edition of an article matching the given context */
|
||||
public function editionLatest(string $user, Context $context = null): int {
|
||||
public function editionLatest(string $user, ?RootContext $context = null): int {
|
||||
$context = $context ?? new Context;
|
||||
$q = $this->articleQuery($user, $context, ["latest_edition"]);
|
||||
return (int) $this->db->prepare((string) $q, $q->getTypes())->run($q->getValues())->getValue();
|
||||
|
@ -2129,7 +2215,6 @@ class Database {
|
|||
* @param boolean $includeEmpty Whether to include (true) or supress (false) labels which have no articles assigned to them
|
||||
*/
|
||||
public function labelList(string $user, bool $includeEmpty = true): Db\Result {
|
||||
$integer = $this->db->sqlToken("integer");
|
||||
return $this->db->prepareArray(
|
||||
"SELECT * FROM (
|
||||
SELECT
|
||||
|
@ -2145,7 +2230,7 @@ class Database {
|
|||
SELECT
|
||||
label,
|
||||
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
|
||||
join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription
|
||||
join arsse_label_members on arsse_label_members.article = arsse_marks.article
|
||||
|
@ -2195,7 +2280,6 @@ class Database {
|
|||
$this->labelValidateId($user, $id, $byName, false);
|
||||
$field = $byName ? "name" : "id";
|
||||
$type = $byName ? "str" : "int";
|
||||
$integer = $this->db->sqlToken("integer");
|
||||
$out = $this->db->prepareArray(
|
||||
"SELECT
|
||||
id,
|
||||
|
@ -2210,7 +2294,7 @@ class Database {
|
|||
SELECT
|
||||
label,
|
||||
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
|
||||
join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription
|
||||
join arsse_label_members on arsse_label_members.article = arsse_marks.article
|
||||
|
@ -2289,11 +2373,11 @@ class Database {
|
|||
*
|
||||
* @param string $user The owner of the label
|
||||
* @param integer|string $id The numeric identifier or name of the label
|
||||
* @param Context $context The query context matching the desired articles
|
||||
* @param RootContext $context The query context matching the desired articles
|
||||
* @param int $mode Whether to add (ASSOC_ADD), remove (ASSOC_REMOVE), or replace with (ASSOC_REPLACE) the matching associations
|
||||
* @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false)
|
||||
*/
|
||||
public function labelArticlesSet(string $user, $id, Context $context, int $mode = self::ASSOC_ADD, bool $byName = false): int {
|
||||
public function labelArticlesSet(string $user, $id, RootContext $context, int $mode = self::ASSOC_ADD, bool $byName = false): int {
|
||||
assert(in_array($mode, [self::ASSOC_ADD, self::ASSOC_REMOVE, self::ASSOC_REPLACE]), new Exception("constantUnknown", $mode));
|
||||
// validate the tag ID, and get the numeric ID if matching by name
|
||||
$id = $this->labelValidateId($user, $id, $byName, true)['id'];
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
use JKingWeb\Arsse\Arsse;
|
||||
|
@ -19,7 +20,7 @@ abstract class AbstractDriver implements Driver {
|
|||
abstract protected function unlock(bool $rollback = false): bool;
|
||||
abstract protected static function buildEngineException($code, string $msg): array;
|
||||
|
||||
public function schemaUpdate(int $to, string $basePath = null): bool {
|
||||
public function schemaUpdate(int $to, ?string $basePath = null): bool {
|
||||
$ver = $this->schemaVersion();
|
||||
if (!Arsse::$conf->dbAutoUpdate) {
|
||||
throw new Exception("updateManual", ['version' => $ver, 'driver_name' => $this->driverName()]);
|
||||
|
@ -90,7 +91,7 @@ abstract class AbstractDriver implements Driver {
|
|||
return $this->transDepth;
|
||||
}
|
||||
|
||||
public function savepointRelease(int $index = null): bool {
|
||||
public function savepointRelease(?int $index = null): bool {
|
||||
// assume the most recent savepoint if none was specified
|
||||
$index = $index ?? $this->transDepth;
|
||||
if (array_key_exists($index, $this->transStatus)) {
|
||||
|
@ -149,7 +150,7 @@ abstract class AbstractDriver implements Driver {
|
|||
}
|
||||
}
|
||||
|
||||
public function savepointUndo(int $index = null): bool {
|
||||
public function savepointUndo(?int $index = null): bool {
|
||||
$index = $index ?? $this->transDepth;
|
||||
if (array_key_exists($index, $this->transStatus)) {
|
||||
switch ($this->transStatus[$index]) {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
abstract class AbstractResult implements Result {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
use JKingWeb\Arsse\Misc\Date;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
interface Driver {
|
||||
|
@ -38,10 +39,10 @@ interface Driver {
|
|||
public function savepointCreate(): int;
|
||||
|
||||
/** Manually commits either the latest or a specified nested transaction */
|
||||
public function savepointRelease(int $index = null): bool;
|
||||
public function savepointRelease(?int $index = null): bool;
|
||||
|
||||
/** Manually rolls back either the latest or a specified nested transaction */
|
||||
public function savepointUndo(int $index = null): bool;
|
||||
public function savepointUndo(?int $index = null): bool;
|
||||
|
||||
/** Performs an in-place upgrade of the database schema
|
||||
*
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
class Exception extends \JKingWeb\Arsse\AbstractException {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
class ExceptionInput extends \JKingWeb\Arsse\AbstractException {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
class ExceptionRetry extends \JKingWeb\Arsse\AbstractException {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
class ExceptionTimeout extends \JKingWeb\Arsse\AbstractException {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\MySQL;
|
||||
|
||||
use JKingWeb\Arsse\Arsse;
|
||||
|
@ -81,8 +82,6 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
|
|||
switch (strtolower($token)) {
|
||||
case "nocase":
|
||||
return '"utf8mb4_unicode_ci"';
|
||||
case "integer":
|
||||
return "signed integer";
|
||||
case "asc":
|
||||
return "";
|
||||
default:
|
||||
|
@ -100,7 +99,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
|
|||
}
|
||||
}
|
||||
|
||||
public function savepointRelease(int $index = null): bool {
|
||||
public function savepointRelease(?int $index = null): bool {
|
||||
$index = $index ?? $this->transDepth;
|
||||
$out = parent::savepointRelease($index);
|
||||
if ($index == $this->transStart) {
|
||||
|
@ -110,7 +109,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
|
|||
return $out;
|
||||
}
|
||||
|
||||
public function savepointUndo(int $index = null): bool {
|
||||
public function savepointUndo(?int $index = null): bool {
|
||||
$index = $index ?? $this->transDepth;
|
||||
$out = parent::savepointUndo($index);
|
||||
if ($index == $this->transStart) {
|
||||
|
@ -224,7 +223,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
|
|||
|
||||
public function maintenance(): bool {
|
||||
// with MySQL each table must be analyzed separately, so we first have to get a list of tables
|
||||
foreach ($this->query("SHOW TABLES like 'arsse\\_%'") as $table) {
|
||||
foreach ($this->query("SHOW TABLES like 'arsse%'") as $table) {
|
||||
$table = array_pop($table);
|
||||
if (!preg_match("/^arsse_[a-z_]+$/D", $table)) {
|
||||
// table is not one of ours
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\MySQL;
|
||||
|
||||
use JKingWeb\Arsse\Db\Exception;
|
||||
|
@ -27,7 +28,7 @@ trait ExceptionBuilder {
|
|||
public static function buildConnectionException($code, string $msg): array {
|
||||
switch ($code) {
|
||||
case 1045:
|
||||
// @codeCoverageIgnoreStart
|
||||
// @codeCoverageIgnoreStart
|
||||
case 1043:
|
||||
case 1044:
|
||||
case 1046:
|
||||
|
@ -48,7 +49,7 @@ trait ExceptionBuilder {
|
|||
case 2018:
|
||||
case 2026:
|
||||
case 2028:
|
||||
// @codeCoverageIgnoreEnd
|
||||
// @codeCoverageIgnoreEnd
|
||||
return [Exception::class, 'connectionFailure', ['engine' => "MySQL", 'message' => $msg]];
|
||||
default:
|
||||
return [Exception::class, 'engineErrorGeneral', $msg]; // @codeCoverageIgnore
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\MySQL;
|
||||
|
||||
use JKingWeb\Arsse\Arsse;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\MySQL;
|
||||
|
||||
class PDOStatement extends \JKingWeb\Arsse\Db\PDOStatement {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\MySQL;
|
||||
|
||||
class Result extends \JKingWeb\Arsse\Db\AbstractResult {
|
||||
|
@ -25,7 +26,7 @@ class Result extends \JKingWeb\Arsse\Db\AbstractResult {
|
|||
|
||||
// constructor/destructor
|
||||
|
||||
public function __construct($result, array $changes = [0,0], Statement $statement = null) {
|
||||
public function __construct($result, array $changes = [0,0], ?Statement $statement = null) {
|
||||
$this->st = $statement; //keeps the statement from being destroyed, invalidating the result set
|
||||
$this->set = ($result instanceof \mysqli_result) ? $result : null;
|
||||
$this->rows = $changes[0];
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\MySQL;
|
||||
|
||||
class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
|
||||
|
@ -15,7 +16,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
|
|||
self::T_DATETIME => "s",
|
||||
self::T_BINARY => "b",
|
||||
self::T_STRING => "s",
|
||||
self::T_BOOLEAN => "i",
|
||||
self::T_BOOLEAN => "i", // NOTE: Integers are used rather than booleans so that they may be manipulated arithmetically
|
||||
];
|
||||
|
||||
protected $db;
|
||||
|
|
|
@ -4,11 +4,14 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
trait PDODriver {
|
||||
use PDOError;
|
||||
|
||||
protected $db;
|
||||
|
||||
public function exec(string $query): bool {
|
||||
try {
|
||||
$this->db->exec($query);
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
trait PDOError {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
class PDOResult extends AbstractResult {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
abstract class PDOStatement extends AbstractStatement {
|
||||
|
@ -15,7 +16,7 @@ abstract class PDOStatement extends AbstractStatement {
|
|||
self::T_DATETIME => \PDO::PARAM_STR,
|
||||
self::T_BINARY => \PDO::PARAM_LOB,
|
||||
self::T_STRING => \PDO::PARAM_STR,
|
||||
self::T_BOOLEAN => \PDO::PARAM_INT, // FIXME: using \PDO::PARAM_BOOL leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3
|
||||
self::T_BOOLEAN => \PDO::PARAM_INT, // NOTE: Integers are used rather than booleans so that they may be manipulated arithmetically
|
||||
];
|
||||
|
||||
protected $st;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\PostgreSQL;
|
||||
|
||||
trait Dispatch {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\PostgreSQL;
|
||||
|
||||
use JKingWeb\Arsse\Arsse;
|
||||
|
@ -138,7 +139,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
|
|||
}
|
||||
}
|
||||
|
||||
public function savepointRelease(int $index = null): bool {
|
||||
public function savepointRelease(?int $index = null): bool {
|
||||
$index = $index ?? $this->transDepth;
|
||||
$out = parent::savepointRelease($index);
|
||||
if ($index == $this->transStart) {
|
||||
|
@ -148,7 +149,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
|
|||
return $out;
|
||||
}
|
||||
|
||||
public function savepointUndo(int $index = null): bool {
|
||||
public function savepointUndo(?int $index = null): bool {
|
||||
$index = $index ?? $this->transDepth;
|
||||
$out = parent::savepointUndo($index);
|
||||
if ($index == $this->transStart) {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\PostgreSQL;
|
||||
|
||||
use JKingWeb\Arsse\Arsse;
|
||||
|
|
|
@ -4,11 +4,11 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\PostgreSQL;
|
||||
|
||||
class PDOResult extends \JKingWeb\Arsse\Db\PDOResult {
|
||||
|
||||
// This method exists to transparent handle byte-array results
|
||||
// This method exists to transparently handle byte-array results
|
||||
|
||||
public function valid() {
|
||||
$this->cur = $this->set->fetch(\PDO::FETCH_ASSOC);
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\PostgreSQL;
|
||||
|
||||
use JKingWeb\Arsse\Db\Result;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\PostgreSQL;
|
||||
|
||||
class Result extends \JKingWeb\Arsse\Db\AbstractResult {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\PostgreSQL;
|
||||
|
||||
class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
|
||||
|
@ -15,7 +16,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
|
|||
self::T_DATETIME => "timestamp(0) without time zone",
|
||||
self::T_BINARY => "bytea",
|
||||
self::T_STRING => "text",
|
||||
self::T_BOOLEAN => "smallint", // FIXME: using boolean leads to incompatibilities with versions of SQLite bundled prior to PHP 7.3
|
||||
self::T_BOOLEAN => "smallint", // NOTE: Integers are used rather than booleans so that they may be manipulated arithmetically
|
||||
];
|
||||
|
||||
protected $db;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
interface Result extends \Iterator {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
class ResultAggregate extends AbstractResult {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
class ResultEmpty extends AbstractResult {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
trait SQLState {
|
||||
|
|
|
@ -4,10 +4,13 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\SQLite3;
|
||||
|
||||
abstract class AbstractPDODriver extends Driver {
|
||||
// this class exists solely so SQLite's PDO driver can call methods of the generic PDO driver via parent::method()
|
||||
// if there's a better way to do this, please FIXME ;)
|
||||
use \JKingWeb\Arsse\Db\PDODriver;
|
||||
|
||||
protected $db;
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\SQLite3;
|
||||
|
||||
use JKingWeb\Arsse\Arsse;
|
||||
|
@ -20,6 +21,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
|
|||
public const SQLITE_MISMATCH = 20;
|
||||
|
||||
protected $db;
|
||||
protected $collator;
|
||||
|
||||
public function __construct() {
|
||||
// check to make sure required extension is loaded
|
||||
|
@ -120,6 +122,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
|
|||
switch (strtolower($token)) {
|
||||
case "greatest":
|
||||
return "max";
|
||||
case "least":
|
||||
return "min";
|
||||
case "asc":
|
||||
return "";
|
||||
default:
|
||||
|
@ -127,7 +131,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
|
|||
}
|
||||
}
|
||||
|
||||
public function schemaUpdate(int $to, string $basePath = null): bool {
|
||||
public function schemaUpdate(int $to, ?string $basePath = null): bool {
|
||||
if ($to == 1) {
|
||||
// if we're initializing the database for the first time, switch to WAL mode
|
||||
$this->exec("PRAGMA journal_mode = wal");
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\SQLite3;
|
||||
|
||||
use JKingWeb\Arsse\Db\Exception;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\SQLite3;
|
||||
|
||||
use JKingWeb\Arsse\Arsse;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\SQLite3;
|
||||
|
||||
class PDOStatement extends \JKingWeb\Arsse\Db\PDOStatement {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\SQLite3;
|
||||
|
||||
class Result extends \JKingWeb\Arsse\Db\AbstractResult {
|
||||
|
@ -25,7 +26,7 @@ class Result extends \JKingWeb\Arsse\Db\AbstractResult {
|
|||
|
||||
// constructor/destructor
|
||||
|
||||
public function __construct(\SQLite3Result $result, array $changes = [0,0], Statement $statement = null) {
|
||||
public function __construct(\SQLite3Result $result, array $changes = [0,0], ?Statement $statement = null) {
|
||||
$this->st = $statement; //keeps the statement from being destroyed, invalidating the result set
|
||||
$this->set = $result;
|
||||
$this->rows = $changes[0];
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db\SQLite3;
|
||||
|
||||
class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
|
||||
|
@ -18,7 +19,7 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
|
|||
self::T_DATETIME => \SQLITE3_TEXT,
|
||||
self::T_BINARY => \SQLITE3_BLOB,
|
||||
self::T_STRING => \SQLITE3_TEXT,
|
||||
self::T_BOOLEAN => \SQLITE3_INTEGER,
|
||||
self::T_BOOLEAN => \SQLITE3_INTEGER, // NOTE: Integers are used rather than booleans so that they may be manipulated arithmetically
|
||||
];
|
||||
|
||||
protected $db;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
interface Statement {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Db;
|
||||
|
||||
class Transaction {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse;
|
||||
|
||||
class Exception extends AbstractException {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse;
|
||||
|
||||
class ExceptionFatal extends AbstractException {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse;
|
||||
|
||||
class ExceptionType extends AbstractException {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse;
|
||||
|
||||
class Factory {
|
||||
|
|
110
lib/Feed.php
110
lib/Feed.php
|
@ -4,8 +4,10 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse;
|
||||
|
||||
use JKingWeb\Arsse\Feed\Item;
|
||||
use JKingWeb\Arsse\Misc\Date;
|
||||
use JKingWeb\Arsse\Rule\Rule;
|
||||
use PicoFeed\PicoFeedException;
|
||||
|
@ -16,62 +18,62 @@ use PicoFeed\Reader\Favicon;
|
|||
use PicoFeed\Scraper\Scraper;
|
||||
|
||||
class Feed {
|
||||
public $data = null;
|
||||
public $title;
|
||||
public $siteUrl;
|
||||
public $iconUrl;
|
||||
public $iconType;
|
||||
public $iconData;
|
||||
public $resource;
|
||||
public $modified = false;
|
||||
public $lastModified;
|
||||
public $etag;
|
||||
public $nextFetch;
|
||||
public $items = [];
|
||||
public $newItems = [];
|
||||
public $changedItems = [];
|
||||
public $filteredItems = [];
|
||||
|
||||
public static function discover(string $url, string $username = '', string $password = ''): string {
|
||||
// fetch the candidate feed
|
||||
$f = self::download($url, "", "", $username, $password);
|
||||
if ($f->reader->detectFormat($f->getContent())) {
|
||||
[$client, $reader] = self::download($url, "", "", $username, $password);
|
||||
if ($reader->detectFormat($client->getContent())) {
|
||||
// if the prospective URL is a feed, use it
|
||||
$out = $url;
|
||||
} else {
|
||||
$links = $f->reader->find($f->getUrl(), $f->getContent());
|
||||
$links = $reader->find($client->getUrl(), $client->getContent());
|
||||
if (!$links) {
|
||||
// work around a PicoFeed memory leak
|
||||
libxml_use_internal_errors(false);
|
||||
throw new Feed\Exception("", ['url' => $url], new \PicoFeed\Reader\SubscriptionNotFoundException('Unable to find a subscription'));
|
||||
} else {
|
||||
$out = $links[0];
|
||||
}
|
||||
}
|
||||
// work around a PicoFeed memory leak
|
||||
libxml_use_internal_errors(false);
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function discoverAll(string $url, string $username = '', string $password = ''): array {
|
||||
// fetch the candidate feed
|
||||
$f = self::download($url, "", "", $username, $password);
|
||||
if ($f->reader->detectFormat($f->getContent())) {
|
||||
[$client, $reader] = self::download($url, "", "", $username, $password);
|
||||
if ($reader->detectFormat($client->getContent())) {
|
||||
// if the prospective URL is a feed, use it
|
||||
return [$url];
|
||||
} else {
|
||||
return $f->reader->find($f->getUrl(), $f->getContent());
|
||||
return $reader->find($client->getUrl(), $client->getContent());
|
||||
}
|
||||
}
|
||||
|
||||
public function __construct(int $feedID = null, string $url, string $lastModified = '', string $etag = '', string $username = '', string $password = '', bool $scrape = false) {
|
||||
public function __construct(?int $feedID, string $url, string $lastModified = '', string $etag = '', string $username = '', string $password = '', bool $scrape = false) {
|
||||
// fetch the feed
|
||||
$this->resource = self::download($url, $lastModified, $etag, $username, $password);
|
||||
[$client, $reader] = self::download($url, $lastModified, $etag, $username, $password);
|
||||
// format the HTTP Last-Modified date returned
|
||||
$lastMod = $this->resource->getLastModified();
|
||||
$lastMod = $client->getLastModified();
|
||||
if (strlen($lastMod ?? "")) {
|
||||
$this->lastModified = Date::normalize($lastMod, "http");
|
||||
}
|
||||
$this->modified = $this->resource->isModified();
|
||||
//parse the feed, if it has been modified
|
||||
$this->modified = $client->isModified();
|
||||
// get the ETag
|
||||
$this->etag = $client->getEtag();
|
||||
// parse the feed, if it has been modified
|
||||
if ($this->modified) {
|
||||
$this->parse();
|
||||
$this->parse($client, $reader);
|
||||
// ascertain whether there are any articles not in the database
|
||||
$this->matchToDatabase($feedID);
|
||||
// if caching header fields are not sent by the server, try to ascertain a last-modified date from the feed contents
|
||||
|
@ -112,12 +114,11 @@ class Feed {
|
|||
return $config;
|
||||
}
|
||||
|
||||
protected static function download(string $url, string $lastModified, string $etag, string $username, string $password): Client {
|
||||
protected static function download(string $url, string $lastModified, string $etag, string $username, string $password): array {
|
||||
try {
|
||||
$reader = new Reader(self::configure());
|
||||
$client = $reader->download($url, $lastModified, $etag, $username, $password);
|
||||
$client->reader = $reader;
|
||||
return $client;
|
||||
return [$client, $reader];
|
||||
} catch (PicoFeedException $e) {
|
||||
throw new Feed\Exception("", ['url' => $url], $e); // @codeCoverageIgnore
|
||||
} catch (\GuzzleHttp\Exception\GuzzleException $e) {
|
||||
|
@ -125,17 +126,17 @@ class Feed {
|
|||
}
|
||||
}
|
||||
|
||||
protected function parse(): void {
|
||||
protected function parse(Client $client, Reader $reader): void {
|
||||
try {
|
||||
$feed = $this->resource->reader->getParser(
|
||||
$this->resource->getUrl(),
|
||||
$this->resource->getContent(),
|
||||
$this->resource->getEncoding()
|
||||
$feed = $reader->getParser(
|
||||
$client->getUrl(),
|
||||
$client->getContent(),
|
||||
$client->getEncoding()
|
||||
)->execute();
|
||||
} catch (PicoFeedException $e) {
|
||||
throw new Feed\Exception("", ['url' => $this->resource->getUrl()], $e);
|
||||
throw new Feed\Exception("", ['url' => $client->getUrl()], $e);
|
||||
} catch (\GuzzleHttp\Exception\GuzzleException $e) { // @codeCoverageIgnore
|
||||
throw new Feed\Exception("", ['url' => $this->resource->getUrl()], $e); // @codeCoverageIgnore
|
||||
throw new Feed\Exception("", ['url' => $client->getUrl()], $e); // @codeCoverageIgnore
|
||||
}
|
||||
|
||||
// Grab the favicon for the feed, or null if no valid icon is found
|
||||
|
@ -150,6 +151,10 @@ class Feed {
|
|||
$this->iconUrl = $this->iconData = null;
|
||||
}
|
||||
|
||||
// Next gather all other feed-level information we want out of the feed
|
||||
$this->siteUrl = $feed->siteUrl;
|
||||
$this->title = $feed->title;
|
||||
|
||||
// PicoFeed does not provide valid ids when there is no id element. Its solution
|
||||
// of hashing the url, title, and content together for the id if there is no id
|
||||
// element is stupid. Many feeds are frankenstein mixtures of Atom and RSS, but
|
||||
|
@ -158,29 +163,38 @@ class Feed {
|
|||
// only be reserved for severely broken feeds.
|
||||
|
||||
foreach ($feed->items as $f) {
|
||||
// Hashes used for comparison to check for updates and also to identify when an
|
||||
// copy the basic information of an article
|
||||
$i = new Item;
|
||||
$i->url = $f->url;
|
||||
$i->title = $f->title;
|
||||
$i->content = $f->content;
|
||||
$i->author = $f->author;
|
||||
$i->publishedDate = $f->publishedDate;
|
||||
$i->updatedDate = $f->updatedDate;
|
||||
$i->enclosureType = $f->enclosureType;
|
||||
$i->enclosureUrl = $f->enclosureUrl;
|
||||
// add hashes used for comparison to check for updates and also to identify when an
|
||||
// id doesn't exist.
|
||||
$content = $f->content.$f->enclosureUrl.$f->enclosureType;
|
||||
// if the item link URL and item title are both equal to the feed link URL, then the item has neither a link URL nor a title
|
||||
if ($f->url === $feed->siteUrl && $f->title === $feed->siteUrl) {
|
||||
$f->urlTitleHash = "";
|
||||
$i->urlTitleHash = "";
|
||||
} else {
|
||||
$f->urlTitleHash = hash('sha256', $f->url.$f->title);
|
||||
$i->urlTitleHash = hash('sha256', $f->url.$f->title);
|
||||
}
|
||||
// if the item link URL is equal to the feed link URL, it has no link URL; if there is additionally no content, these should not be hashed
|
||||
if (!strlen($content) && $f->url === $feed->siteUrl) {
|
||||
$f->urlContentHash = "";
|
||||
$i->urlContentHash = "";
|
||||
} else {
|
||||
$f->urlContentHash = hash('sha256', $f->url.$content);
|
||||
$i->urlContentHash = hash('sha256', $f->url.$content);
|
||||
}
|
||||
// if the item's title is the same as its link URL, it has no title; if there is additionally no content, these should not be hashed
|
||||
if (!strlen($content) && $f->title === $f->url) {
|
||||
$f->titleContentHash = "";
|
||||
$i->titleContentHash = "";
|
||||
} else {
|
||||
$f->titleContentHash = hash('sha256', $f->title.$content);
|
||||
$i->titleContentHash = hash('sha256', $f->title.$content);
|
||||
}
|
||||
$f->id = null;
|
||||
// prefer an Atom ID as the item's ID
|
||||
// next add an id; prefer an Atom ID as the item's ID
|
||||
$id = (string) $f->xml->children('http://www.w3.org/2005/Atom')->id;
|
||||
// otherwise use the RSS2 guid element
|
||||
if (!strlen($id)) {
|
||||
|
@ -192,11 +206,10 @@ class Feed {
|
|||
}
|
||||
// otherwise there is no ID; if there is one, hash it
|
||||
if (strlen($id)) {
|
||||
$f->id = hash('sha256', $id);
|
||||
$i->id = hash('sha256', $id);
|
||||
}
|
||||
|
||||
// PicoFeed also doesn't gather up categories, so we do this as well
|
||||
$f->categories = [];
|
||||
// first add Atom categories
|
||||
foreach ($f->xml->children('http://www.w3.org/2005/Atom')->category as $c) {
|
||||
// if the category has a label, use that
|
||||
|
@ -207,27 +220,28 @@ class Feed {
|
|||
}
|
||||
// ... assuming it has that much
|
||||
if (strlen($name)) {
|
||||
$f->categories[] = $name;
|
||||
$i->categories[] = $name;
|
||||
}
|
||||
}
|
||||
// next add RSS2 categories
|
||||
foreach ($f->xml->children()->category as $c) {
|
||||
$name = (string) $c;
|
||||
if (strlen($name)) {
|
||||
$f->categories[] = $name;
|
||||
$i->categories[] = $name;
|
||||
}
|
||||
}
|
||||
// and finally try Dublin Core subjects
|
||||
foreach ($f->xml->children('http://purl.org/dc/elements/1.1/')->subject as $c) {
|
||||
$name = (string) $c;
|
||||
if (strlen($name)) {
|
||||
$f->categories[] = $name;
|
||||
$i->categories[] = $name;
|
||||
}
|
||||
}
|
||||
//sort the results
|
||||
sort($f->categories);
|
||||
sort($i->categories);
|
||||
// add the item to the feed's list of items
|
||||
$this->items[] = $i;
|
||||
}
|
||||
$this->data = $feed;
|
||||
}
|
||||
|
||||
protected function deduplicateItems(array $items): array {
|
||||
|
@ -251,7 +265,7 @@ class Feed {
|
|||
($item->urlContentHash && $item->urlContentHash === $check->urlContentHash) ||
|
||||
($item->titleContentHash && $item->titleContentHash === $check->titleContentHash)
|
||||
) {
|
||||
if (// because newsfeeds are usually order newest-first, the later item should only be used if...
|
||||
if (// because newsfeeds are usually ordered newest-first, the later item should only be used if...
|
||||
// the later item has an update date and the existing item does not
|
||||
($item->updatedDate && !$check->updatedDate) ||
|
||||
// the later item has an update date newer than the existing item's
|
||||
|
@ -274,9 +288,9 @@ class Feed {
|
|||
return $out;
|
||||
}
|
||||
|
||||
protected function matchToDatabase(int $feedID = null): void {
|
||||
protected function matchToDatabase(?int $feedID = null): void {
|
||||
// first perform deduplication on items
|
||||
$items = $this->deduplicateItems($this->data->items);
|
||||
$items = $this->deduplicateItems($this->items);
|
||||
// if we haven't been given a database feed ID to check against, all items are new
|
||||
if (is_null($feedID)) {
|
||||
$this->newItems = $items;
|
||||
|
@ -429,7 +443,7 @@ class Feed {
|
|||
|
||||
protected function gatherDates(): array {
|
||||
$dates = [];
|
||||
foreach ($this->data->items as $item) {
|
||||
foreach ($this->items as $item) {
|
||||
if ($item->updatedDate) {
|
||||
$dates[] = $item->updatedDate->getTimestamp();
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Feed;
|
||||
|
||||
use GuzzleHttp\Exception\BadResponseException;
|
||||
|
@ -15,7 +16,7 @@ class Exception extends \JKingWeb\Arsse\AbstractException {
|
|||
protected const CURL_ERROR_MAP = [1 => "invalidUrl",3 => "invalidUrl",5 => "transmissionError","connectionFailed","connectionFailed","transmissionError","forbidden","unauthorized","transmissionError","transmissionError","transmissionError","transmissionError","connectionFailed","connectionFailed","transmissionError","transmissionError","transmissionError","transmissionError","transmissionError","invalidUrl","transmissionError","transmissionError","transmissionError","transmissionError",28 => "timeout","transmissionError","transmissionError","transmissionError","transmissionError","transmissionError",35 => "invalidCertificate","transmissionError","transmissionError","transmissionError","transmissionError",45 => "transmissionError","unauthorized","maxRedirect",52 => "transmissionError","invalidCertificate","invalidCertificate","transmissionError","transmissionError",58 => "invalidCertificate","invalidCertificate","invalidCertificate","transmissionError","invalidUrl","transmissionError","invalidCertificate","transmissionError","invalidCertificate","forbidden","invalidUrl","forbidden","transmissionError",73 => "transmissionError","transmissionError",77 => "invalidCertificate","invalidUrl",90 => "invalidCertificate","invalidCertificate","transmissionError",94 => "unauthorized","transmissionError","connectionFailed"];
|
||||
protected const HTTP_ERROR_MAP = [401 => "unauthorized",403 => "forbidden",404 => "invalidUrl",408 => "timeout",410 => "invalidUrl",414 => "invalidUrl",451 => "invalidUrl"];
|
||||
|
||||
public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) {
|
||||
public function __construct(string $msgID = "", $vars = null, ?\Throwable $e = null) {
|
||||
if ($msgID === "") {
|
||||
assert($e !== null, new \Exception("Expecting Picofeed or Guzzle exception when no message specified."));
|
||||
if ($e instanceof BadResponseException) {
|
||||
|
@ -30,7 +31,8 @@ class Exception extends \JKingWeb\Arsse\AbstractException {
|
|||
} elseif (preg_match("/^cURL error (\d+):/", $msg, $match)) {
|
||||
$msgID = self::CURL_ERROR_MAP[(int) $match[1]] ?? "internalError";
|
||||
} else {
|
||||
$msgID = "internalError";
|
||||
// Fallback for future Guzzle exceptions we may not know about
|
||||
$msgID = "internalError"; // @codeCoverageIgnore
|
||||
}
|
||||
} elseif ($e instanceof PicoFeedException) {
|
||||
$className = get_class($e);
|
||||
|
|
25
lib/Feed/Item.php
Normal file
25
lib/Feed/Item.php
Normal file
|
@ -0,0 +1,25 @@
|
|||
<?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\Feed;
|
||||
|
||||
class Item {
|
||||
public $id;
|
||||
public $url;
|
||||
public $title;
|
||||
public $author;
|
||||
public $publishedDate;
|
||||
public $updatedDate;
|
||||
public $urlContentHash;
|
||||
public $urlTitleHash;
|
||||
public $titleContentHash;
|
||||
public $content;
|
||||
public $scrapedContent;
|
||||
public $enclosureUrl;
|
||||
public $enclosureType;
|
||||
public $categories = [];
|
||||
}
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\ImportExport;
|
||||
|
||||
use JKingWeb\Arsse\Arsse;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\ImportExport;
|
||||
|
||||
class Exception extends \JKingWeb\Arsse\AbstractException {
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\ImportExport;
|
||||
|
||||
use JKingWeb\Arsse\Arsse;
|
||||
|
|
31
lib/Lang.php
31
lib/Lang.php
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse;
|
||||
|
||||
class Lang {
|
||||
|
@ -123,7 +124,7 @@ class Lang {
|
|||
return $out;
|
||||
}
|
||||
|
||||
public function match(string $locale, array $list = null): string {
|
||||
public function match(string $locale, ?array $list = null): string {
|
||||
$list = $list ?? $this->listFiles();
|
||||
$default = ($this->locale === "") ? self::DEFAULT : $this->locale;
|
||||
return \Locale::lookup($list, $locale, true, $default);
|
||||
|
@ -160,12 +161,12 @@ class Lang {
|
|||
if (!$this->requirementsMet) {
|
||||
$this->checkRequirements();
|
||||
}
|
||||
$this->synched = true;
|
||||
$this->formatter = null;
|
||||
// if we've requested no locale (""), just load the fallback strings and return
|
||||
if ($this->wanted === "") {
|
||||
$this->strings = self::REQUIRED;
|
||||
$this->locale = $this->wanted;
|
||||
$this->synched = true;
|
||||
$this->formatter = null;
|
||||
return true;
|
||||
}
|
||||
// decompose the requested locale from specific to general, building a list of files to load
|
||||
|
@ -190,16 +191,14 @@ class Lang {
|
|||
$files[] = $file;
|
||||
}
|
||||
// if we need to load all files, start with the fallback strings
|
||||
$strings = [];
|
||||
if ($files === $loaded) {
|
||||
$strings[] = self::REQUIRED;
|
||||
} else {
|
||||
// otherwise start with the strings we already have if we're going from e.g. "fr" to "fr_ca"
|
||||
$strings[] = $this->strings;
|
||||
$this->strings = self::REQUIRED;
|
||||
$this->locale = "";
|
||||
}
|
||||
// read files in reverse order
|
||||
$files = array_reverse($files);
|
||||
foreach ($files as $file) {
|
||||
$this->loaded = array_diff($loaded, $files);
|
||||
while ($files) {
|
||||
// read files in reverse order, from most general to most specific
|
||||
$file = array_pop($files);
|
||||
if (!file_exists($this->path."$file.php")) {
|
||||
throw new Lang\Exception("fileMissing", $file);
|
||||
} elseif (!is_readable($this->path."$file.php")) {
|
||||
|
@ -217,14 +216,10 @@ class Lang {
|
|||
if (!is_array($arr)) {
|
||||
throw new Lang\Exception("fileCorrupt", $file);
|
||||
}
|
||||
$strings[] = $arr;
|
||||
$this->strings = array_replace_recursive($this->strings, $arr);
|
||||
$this->loaded[] = $file;
|
||||
$this->locale = $file;
|
||||
}
|
||||
// apply the results and return
|
||||
$this->strings = call_user_func_array("array_replace_recursive", $strings);
|
||||
$this->loaded = $loaded;
|
||||
$this->locale = $this->wanted;
|
||||
$this->synched = true;
|
||||
$this->formatter = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Lang;
|
||||
|
||||
class Exception extends \JKingWeb\Arsse\AbstractException {
|
||||
|
|
|
@ -4,10 +4,11 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Misc;
|
||||
|
||||
abstract class Date {
|
||||
public static function transform($date, string $outFormat = null, string $inFormat = null) {
|
||||
public static function transform($date, ?string $outFormat = null, ?string $inFormat = null) {
|
||||
$date = ValueInfo::normalize($date, ValueInfo::T_DATE, $inFormat);
|
||||
if (!$date) {
|
||||
return null;
|
||||
|
@ -21,7 +22,7 @@ abstract class Date {
|
|||
return $out;
|
||||
}
|
||||
|
||||
public static function normalize($date, string $inFormat = null): ?\DateTimeImmutable {
|
||||
public static function normalize($date, ?string $inFormat = null): ?\DateTimeImmutable {
|
||||
return ValueInfo::normalize($date, ValueInfo::T_DATE, $inFormat);
|
||||
}
|
||||
|
||||
|
|
|
@ -4,19 +4,45 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Misc;
|
||||
|
||||
use Psr\Http\Message\MessageInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
|
||||
class HTTP {
|
||||
public static function matchType(MessageInterface $msg, string ...$type): bool {
|
||||
$header = $msg->getHeaderLine("Content-Type") ?? "";
|
||||
foreach ($type as $t) {
|
||||
$pattern = "/^".preg_quote(trim($t), "/")."\s*($|;|,)/Di";
|
||||
if (($t[0] ?? "") === "+") {
|
||||
$pattern = "/^[^+;,\s]*".preg_quote(trim($t), "/")."\s*($|;|,)/Di";
|
||||
} else {
|
||||
$pattern = "/^".preg_quote(trim($t), "/")."\s*($|;|,)/Di";
|
||||
}
|
||||
if (preg_match($pattern, $header)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function respEmpty(int $status, ?array $headers = []): ResponseInterface {
|
||||
return new Response($status, $headers ?? []);
|
||||
}
|
||||
|
||||
public static function respJson($body, int $status = 200, ?array $headers = []): ResponseInterface {
|
||||
$headers = ($headers ?? []) + ['Content-Type' => "application/json"];
|
||||
return new Response($status, $headers, json_encode($body, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
public static function respText(string $body, int $status = 200, ?array $headers = []): ResponseInterface {
|
||||
$headers = ($headers ?? []) + ['Content-Type' => "text/plain; charset=UTF-8"];
|
||||
return new Response($status, $headers, $body);
|
||||
}
|
||||
|
||||
public static function respXml(string $body, int $status = 200, ?array $headers = []): ResponseInterface {
|
||||
$headers = ($headers ?? []) + ['Content-Type' => "application/xml; charset=UTF-8"];
|
||||
return new Response($status, $headers, $body);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,21 +4,13 @@
|
|||
* See LICENSE and AUTHORS files for details */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace JKingWeb\Arsse\Misc;
|
||||
|
||||
class Query {
|
||||
class Query extends QueryFilter {
|
||||
protected $qBody = ""; // main query body
|
||||
protected $tBody = []; // main query parameter types
|
||||
protected $vBody = []; // main query parameter values
|
||||
protected $qCTE = []; // Common table expression query components
|
||||
protected $tCTE = []; // Common table expression type bindings
|
||||
protected $vCTE = []; // Common table expression binding values
|
||||
protected $qWhere = []; // WHERE clause components
|
||||
protected $tWhere = []; // WHERE clause type bindings
|
||||
protected $vWhere = []; // WHERE clause binding values
|
||||
protected $qWhereNot = []; // WHERE NOT clause components
|
||||
protected $tWhereNot = []; // WHERE NOT clause type bindings
|
||||
protected $vWhereNot = []; // WHERE NOT clause binding values
|
||||
protected $group = []; // GROUP BY clause components
|
||||
protected $order = []; // ORDER BY clause components
|
||||
protected $limit = 0;
|
||||
|
@ -37,33 +29,6 @@ class Query {
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function setCTE(string $tableSpec, string $body, $types = null, $values = null): self {
|
||||
$this->qCTE[] = "$tableSpec as ($body)";
|
||||
if (!is_null($types)) {
|
||||
$this->tCTE[] = $types;
|
||||
$this->vCTE[] = $values;
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setWhere(string $where, $types = null, $values = null): self {
|
||||
$this->qWhere[] = $where;
|
||||
if (!is_null($types)) {
|
||||
$this->tWhere[] = $types;
|
||||
$this->vWhere[] = $values;
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setWhereNot(string $where, $types = null, $values = null): self {
|
||||
$this->qWhereNot[] = $where;
|
||||
if (!is_null($types)) {
|
||||
$this->tWhereNot[] = $types;
|
||||
$this->vWhereNot[] = $values;
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setGroup(string ...$column): self {
|
||||
foreach ($column as $col) {
|
||||
$this->group[] = $col;
|
||||
|
@ -84,33 +49,8 @@ class Query {
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function pushCTE(string $tableSpec): self {
|
||||
// this function takes the query body and converts it to a common table expression, putting it at the bottom of the existing CTE stack
|
||||
// all WHERE, ORDER BY, and LIMIT parts belong to the new CTE and are removed from the main query
|
||||
$this->setCTE($tableSpec, $this->buildQueryBody(), [$this->tBody, $this->tWhere, $this->tWhereNot], [$this->vBody, $this->vWhere, $this->vWhereNot]);
|
||||
$this->tBody = [];
|
||||
$this->vBody = [];
|
||||
$this->qWhere = [];
|
||||
$this->tWhere = [];
|
||||
$this->vWhere = [];
|
||||
$this->qWhereNot = [];
|
||||
$this->tWhereNot = [];
|
||||
$this->vWhereNot = [];
|
||||
$this->order = [];
|
||||
$this->group = [];
|
||||
$this->setLimit(0, 0);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function __toString(): string {
|
||||
$out = "";
|
||||
if (sizeof($this->qCTE)) {
|
||||
// start with common table expressions
|
||||
$out .= "WITH RECURSIVE ".implode(", ", $this->qCTE)." ";
|
||||
}
|
||||
// add the body
|
||||
$out .= $this->buildQueryBody();
|
||||
return $out;
|
||||
return $this->buildQueryBody();
|
||||
}
|
||||
|
||||
public function getQuery(): string {
|
||||
|
@ -118,11 +58,11 @@ class Query {
|
|||
}
|
||||
|
||||
public function getTypes(): array {
|
||||
return ValueInfo::flatten([$this->tCTE, $this->tBody, $this->tWhere, $this->tWhereNot]);
|
||||
return ValueInfo::flatten([$this->tBody, $this->getWhereTypes()]);
|
||||
}
|
||||
|
||||
public function getValues(): array {
|
||||
return ValueInfo::flatten([$this->vCTE, $this->vBody, $this->vWhere, $this->vWhereNot]);
|
||||
return ValueInfo::flatten([$this->vBody, $this->getWhereValues()]);
|
||||
}
|
||||
|
||||
protected function buildQueryBody(): string {
|
||||
|
@ -131,11 +71,7 @@ class Query {
|
|||
$out .= $this->qBody;
|
||||
// add any WHERE terms
|
||||
if (sizeof($this->qWhere) || sizeof($this->qWhereNot)) {
|
||||
$where = implode(" AND ", $this->qWhere);
|
||||
$whereNot = implode(" OR ", $this->qWhereNot);
|
||||
$whereNot = strlen($whereNot) ? "NOT ($whereNot)" : "";
|
||||
$where = implode(" AND ", array_filter([$where, $whereNot]));
|
||||
$out .= " WHERE $where";
|
||||
$out .= " WHERE ".$this->buildWhereBody();
|
||||
}
|
||||
// add any GROUP BY terms
|
||||
if (sizeof($this->group)) {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue