mirror of
https://code.mensbeam.com/MensBeam/Arsse.git
synced 2024-12-22 21:22:40 +00:00
Imprement setting of filter rules
This commit is contained in:
parent
618fd67f80
commit
9f2b8d4f83
4 changed files with 131 additions and 55 deletions
|
@ -46,6 +46,7 @@ abstract class AbstractException extends \Exception {
|
||||||
"Db/Exception.savepointStale" => 10227,
|
"Db/Exception.savepointStale" => 10227,
|
||||||
"Db/Exception.resultReused" => 10228,
|
"Db/Exception.resultReused" => 10228,
|
||||||
"Db/ExceptionRetry.schemaChange" => 10229,
|
"Db/ExceptionRetry.schemaChange" => 10229,
|
||||||
|
"Db/ExceptionInput.invalidValue" => 10230,
|
||||||
"Db/ExceptionInput.missing" => 10231,
|
"Db/ExceptionInput.missing" => 10231,
|
||||||
"Db/ExceptionInput.whitespace" => 10232,
|
"Db/ExceptionInput.whitespace" => 10232,
|
||||||
"Db/ExceptionInput.tooLong" => 10233,
|
"Db/ExceptionInput.tooLong" => 10233,
|
||||||
|
|
132
lib/Database.php
132
lib/Database.php
|
@ -754,6 +754,28 @@ class Database {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lists a user's subscriptions, returning various data
|
/** Lists a user's subscriptions, returning various data
|
||||||
|
*
|
||||||
|
* Each record has the following keys:
|
||||||
|
*
|
||||||
|
* - "id": The numeric identifier of the subscription
|
||||||
|
* - "feed": The numeric identifier of the underlying newsfeed
|
||||||
|
* - "url": The URL of the newsfeed, after discovery and HTTP redirects
|
||||||
|
* - "title": The title of the newsfeed
|
||||||
|
* - "source": The URL of the source of the newsfeed i.e. its parent Web site
|
||||||
|
* - "favicon": The URL of an icon representing the newsfeed or its source
|
||||||
|
* - "folder": The numeric identifier (or null) of the subscription's folder
|
||||||
|
* - "top_folder": The numeric identifier (or null) of the top-level folder for the subscription
|
||||||
|
* - "pinned": Whether the subscription is pinned
|
||||||
|
* - "err_count": The count of times attempting to refresh the newsfeed has resulted in an error since the last successful retrieval
|
||||||
|
* - "err_msg": The error message of the last unsuccessful retrieval
|
||||||
|
* - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0)
|
||||||
|
* - "keep_rule": The subscription's "keep" filter rule; articles which do not match this are hidden
|
||||||
|
* - "block_rule": The subscription's "block" filter rule; articles which match this are hidden
|
||||||
|
* - "added": The date and time at which the subscription was added
|
||||||
|
* - "updated": The date and time at which the newsfeed was last updated in the database
|
||||||
|
* - "edited": The date and time at which the newsfeed was last modified by its authors
|
||||||
|
* - "modified": The date and time at which the subscription properties were last changed by the user
|
||||||
|
* - "unread": The number of unread articles associated with the subscription
|
||||||
*
|
*
|
||||||
* @param string $user The user whose subscriptions are to be listed
|
* @param string $user The user whose subscriptions are to be listed
|
||||||
* @param integer|null $folder The identifier of the folder under which to list subscriptions; by default the root folder is used
|
* @param integer|null $folder The identifier of the folder under which to list subscriptions; by default the root folder is used
|
||||||
|
@ -817,7 +839,11 @@ class Database {
|
||||||
return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
|
return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the number of subscriptions in a folder, counting recursively */
|
/** Returns the number of subscriptions in a folder, counting recursively
|
||||||
|
*
|
||||||
|
* @param string $user The user whose subscriptions are to be counted
|
||||||
|
* @param integer|null $folder The identifier of the folder under which to count subscriptions; by default the root folder is used
|
||||||
|
*/
|
||||||
public function subscriptionCount(string $user, $folder = null): int {
|
public function subscriptionCount(string $user, $folder = null): int {
|
||||||
// validate inputs
|
// validate inputs
|
||||||
$folder = $this->folderValidateId($user, $folder)['id'];
|
$folder = $this->folderValidateId($user, $folder)['id'];
|
||||||
|
@ -851,24 +877,7 @@ class Database {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Retrieves data about a particular subscription, as an associative array with the following keys:
|
/** Retrieves data about a particular subscription, as an associative array; see subscriptionList for details */
|
||||||
*
|
|
||||||
* - "id": The numeric identifier of the subscription
|
|
||||||
* - "feed": The numeric identifier of the underlying newsfeed
|
|
||||||
* - "url": The URL of the newsfeed, after discovery and HTTP redirects
|
|
||||||
* - "title": The title of the newsfeed
|
|
||||||
* - "favicon": The URL of an icon representing the newsfeed or its source
|
|
||||||
* - "source": The URL of the source of the newsfeed i.e. its parent Web site
|
|
||||||
* - "folder": The numeric identifier (or null) of the subscription's folder
|
|
||||||
* - "top_folder": The numeric identifier (or null) of the top-level folder for the subscription
|
|
||||||
* - "pinned": Whether the subscription is pinned
|
|
||||||
* - "err_count": The count of times attempting to refresh the newsfeed has resulted in an error since the last successful retrieval
|
|
||||||
* - "err_msg": The error message of the last unsuccessful retrieval
|
|
||||||
* - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0)
|
|
||||||
* - "added": The date and time at which the subscription was added
|
|
||||||
* - "updated": The date and time at which the newsfeed was last updated (not when it was last refreshed)
|
|
||||||
* - "unread": The number of unread articles associated with the subscription
|
|
||||||
*/
|
|
||||||
public function subscriptionPropertiesGet(string $user, $id): array {
|
public function subscriptionPropertiesGet(string $user, $id): array {
|
||||||
if (!V::id($id)) {
|
if (!V::id($id)) {
|
||||||
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'type' => "int > 0"]);
|
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'type' => "int > 0"]);
|
||||||
|
@ -884,10 +893,12 @@ class Database {
|
||||||
*
|
*
|
||||||
* The $data array must contain one or more of the following keys:
|
* The $data array must contain one or more of the following keys:
|
||||||
*
|
*
|
||||||
* - "title": The title of the newsfeed
|
* - "title": The title of the subscription
|
||||||
* - "folder": The numeric identifier (or null) of the subscription's folder
|
* - "folder": The numeric identifier (or null) of the subscription's folder
|
||||||
* - "pinned": Whether the subscription is pinned
|
* - "pinned": Whether the subscription is pinned
|
||||||
* - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0)
|
* - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0)
|
||||||
|
* - "keep_rule": The subscription's "keep" filter rule; articles which do not match this are hidden
|
||||||
|
* - "block_rule": The subscription's "block" filter rule; articles which match this are hidden
|
||||||
*
|
*
|
||||||
* @param string $user The user whose subscription is to be modified
|
* @param string $user The user whose subscription is to be modified
|
||||||
* @param integer $id the numeric identifier of the subscription to modfify
|
* @param integer $id the numeric identifier of the subscription to modfify
|
||||||
|
@ -896,29 +907,45 @@ class Database {
|
||||||
public function subscriptionPropertiesSet(string $user, $id, array $data): bool {
|
public function subscriptionPropertiesSet(string $user, $id, array $data): bool {
|
||||||
$tr = $this->db->begin();
|
$tr = $this->db->begin();
|
||||||
// validate the ID
|
// validate the ID
|
||||||
$id = $this->subscriptionValidateId($user, $id, true)['id'];
|
$id = (int) $this->subscriptionValidateId($user, $id, true)['id'];
|
||||||
if (array_key_exists("folder", $data)) {
|
if (array_key_exists("folder", $data)) {
|
||||||
// ensure the target folder exists and belong to the user
|
// ensure the target folder exists and belong to the user
|
||||||
$data['folder'] = $this->folderValidateId($user, $data['folder'])['id'];
|
$data['folder'] = $this->folderValidateId($user, $data['folder'])['id'];
|
||||||
}
|
}
|
||||||
if (array_key_exists("title", $data)) {
|
if (isset($data['title'])) {
|
||||||
// if the title is null, this signals intended use of the default title; otherwise make sure it's not effectively an empty string
|
// if the title is null, this signals intended use of the default title; otherwise make sure it's not effectively an empty string
|
||||||
if (!is_null($data['title'])) {
|
$info = V::str($data['title']);
|
||||||
$info = V::str($data['title']);
|
if ($info & V::EMPTY) {
|
||||||
if ($info & V::EMPTY) {
|
throw new Db\ExceptionInput("missing", ["action" => __FUNCTION__, "field" => "title"]);
|
||||||
throw new Db\ExceptionInput("missing", ["action" => __FUNCTION__, "field" => "title"]);
|
} elseif ($info & V::WHITE) {
|
||||||
} elseif ($info & V::WHITE) {
|
throw new Db\ExceptionInput("whitespace", ["action" => __FUNCTION__, "field" => "title"]);
|
||||||
throw new Db\ExceptionInput("whitespace", ["action" => __FUNCTION__, "field" => "title"]);
|
} elseif (!($info & V::VALID)) {
|
||||||
} elseif (!($info & V::VALID)) {
|
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "title", 'type' => "string"]);
|
||||||
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "title", 'type' => "string"]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// validate any filter rules
|
||||||
|
if (isset($data['keep_rule'])) {
|
||||||
|
if (!is_string($data['keep_rule'])) {
|
||||||
|
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "keep_rule", 'type' => "string"]);
|
||||||
|
} elseif (!Rule::validate($data['keep_rule'])) {
|
||||||
|
throw new Db\ExceptionInput("invalidValue", ["action" => __FUNCTION__, "field" => "keep_rule"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($data['block_rule'])) {
|
||||||
|
if (!is_string($data['block_rule'])) {
|
||||||
|
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "block_rule", 'type' => "string"]);
|
||||||
|
} elseif (!Rule::validate($data['block_rule'])) {
|
||||||
|
throw new Db\ExceptionInput("invalidValue", ["action" => __FUNCTION__, "field" => "block_rule"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// perform the update
|
||||||
$valid = [
|
$valid = [
|
||||||
'title' => "str",
|
'title' => "str",
|
||||||
'folder' => "int",
|
'folder' => "int",
|
||||||
'order_type' => "strict int",
|
'order_type' => "strict int",
|
||||||
'pinned' => "strict bool",
|
'pinned' => "strict bool",
|
||||||
|
'keep_rule' => "str",
|
||||||
|
'block_rule' => "str",
|
||||||
];
|
];
|
||||||
[$setClause, $setTypes, $setValues] = $this->generateSet($data, $valid);
|
[$setClause, $setTypes, $setValues] = $this->generateSet($data, $valid);
|
||||||
if (!$setClause) {
|
if (!$setClause) {
|
||||||
|
@ -927,6 +954,10 @@ class Database {
|
||||||
}
|
}
|
||||||
$out = (bool) $this->db->prepare("UPDATE arsse_subscriptions set $setClause, modified = CURRENT_TIMESTAMP where owner = ? and id = ?", $setTypes, "str", "int")->run($setValues, $user, $id)->changes();
|
$out = (bool) $this->db->prepare("UPDATE arsse_subscriptions set $setClause, modified = CURRENT_TIMESTAMP where owner = ? and id = ?", $setTypes, "str", "int")->run($setValues, $user, $id)->changes();
|
||||||
$tr->commit();
|
$tr->commit();
|
||||||
|
// if filter rules were changed, apply them
|
||||||
|
if (array_key_exists("keep_rule", $data) || array_key_exists("block_rule", $data)) {
|
||||||
|
$this->subscriptionRulesApply($user, $id);
|
||||||
|
}
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -984,6 +1015,45 @@ class Database {
|
||||||
return V::normalize($out, V::T_DATE | V::M_NULL, "sql");
|
return V::normalize($out, V::T_DATE | V::M_NULL, "sql");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Evalutes the filter rules specified for a subscription against every article associated with the subscription's feed
|
||||||
|
*
|
||||||
|
* @param string $user The user who owns the subscription
|
||||||
|
* @param integer $id The identifier of the subscription whose rules are to be evaluated
|
||||||
|
*/
|
||||||
|
protected function subscriptionRulesApply(string $user, int $id): void {
|
||||||
|
$sub = $this->db->prepare("SELECT feed, coalesce(keep_rule, '') as keep, coalesce(block_rule, '') as block from arsse_subscriptions where owner = ? and id = ?", "str", "int")->run($user, $id)->getRow();
|
||||||
|
try {
|
||||||
|
$keep = Rule::prep($sub['keep']);
|
||||||
|
$block = Rule::prep($sub['block']);
|
||||||
|
$feed = $sub['feed'];
|
||||||
|
} catch (RuleException $e) {
|
||||||
|
// invalid rules should not normally appear in the database, but it's possible
|
||||||
|
// in this case we should halt evaluation and just leave things as they are
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$articles = $this->db->prepare("SELECT id, title, coalesce(categories, 0) as categories from arsse_articles as a join (select article, count(*) as categories from arsse_categories group by article) as c on a.id = c.article where a.feed = ?", "int")->run($feed)->getAll();
|
||||||
|
$hide = [];
|
||||||
|
$unhide = [];
|
||||||
|
foreach ($articles as $r) {
|
||||||
|
// retrieve the list of categories if the article has any
|
||||||
|
$categories = $r['categories'] ? $this->articleCategoriesGet($user, $r['id']) : [];
|
||||||
|
// evaluate the rule for the article
|
||||||
|
if (Rule::apply($keep, $block, $r['title'], $categories)) {
|
||||||
|
$unhide[] = $r['id'];
|
||||||
|
} else {
|
||||||
|
$hide[] = $r['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// apply any marks
|
||||||
|
if ($hide) {
|
||||||
|
$this->articleMark($user, ['hidden' => true], (new Context)->articles($hide), false);
|
||||||
|
}
|
||||||
|
if ($unhide) {
|
||||||
|
$this->articleMark($user, ['hidden' => false], (new Context)->articles($unhide), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Ensures the specified subscription exists and raises an exception otherwise
|
/** Ensures the specified subscription exists and raises an exception otherwise
|
||||||
*
|
*
|
||||||
* Returns an associative array containing the id of the subscription and the id of the underlying newsfeed
|
* Returns an associative array containing the id of the subscription and the id of the underlying newsfeed
|
||||||
|
|
|
@ -138,6 +138,7 @@ return [
|
||||||
// indicates programming error
|
// indicates programming error
|
||||||
'Exception.JKingWeb/Arsse/Db/Exception.resultReused' => 'Result set already iterated',
|
'Exception.JKingWeb/Arsse/Db/Exception.resultReused' => 'Result set already iterated',
|
||||||
'Exception.JKingWeb/Arsse/Db/ExceptionRetry.schemaChange' => '{0}',
|
'Exception.JKingWeb/Arsse/Db/ExceptionRetry.schemaChange' => '{0}',
|
||||||
|
'Exception.JKingWeb/Arsse/Db/ExceptionInput.invalidValue' => 'Value of field "{field}" of action "{action}" is invalid',
|
||||||
'Exception.JKingWeb/Arsse/Db/ExceptionInput.missing' => 'Required field "{field}" missing while performing action "{action}"',
|
'Exception.JKingWeb/Arsse/Db/ExceptionInput.missing' => 'Required field "{field}" missing while performing action "{action}"',
|
||||||
'Exception.JKingWeb/Arsse/Db/ExceptionInput.whitespace' => 'Field "{field}" of action "{action}" may not contain only whitespace',
|
'Exception.JKingWeb/Arsse/Db/ExceptionInput.whitespace' => 'Field "{field}" of action "{action}" may not contain only whitespace',
|
||||||
'Exception.JKingWeb/Arsse/Db/ExceptionInput.tooLong' => 'Field "{field}" of action "{action}" has a maximum length of {max}',
|
'Exception.JKingWeb/Arsse/Db/ExceptionInput.tooLong' => 'Field "{field}" of action "{action}" has a maximum length of {max}',
|
||||||
|
|
|
@ -77,12 +77,14 @@ trait SeriesSubscription {
|
||||||
'folder' => "int",
|
'folder' => "int",
|
||||||
'pinned' => "bool",
|
'pinned' => "bool",
|
||||||
'order_type' => "int",
|
'order_type' => "int",
|
||||||
|
'keep_rule' => "str",
|
||||||
|
'block_rule' => "str",
|
||||||
],
|
],
|
||||||
'rows' => [
|
'rows' => [
|
||||||
[1,"john.doe@example.com",2,null,null,1,2],
|
[1,"john.doe@example.com",2,null,null,1,2,null,null],
|
||||||
[2,"jane.doe@example.com",2,null,null,0,0],
|
[2,"jane.doe@example.com",2,null,null,0,0,null,null],
|
||||||
[3,"john.doe@example.com",3,"Ook",2,0,1],
|
[3,"john.doe@example.com",3,"Ook",2,0,1,null,null],
|
||||||
[4,"jill.doe@example.com",2,null,null,0,0],
|
[4,"jill.doe@example.com",2,null,null,0,0,null,null],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'arsse_tags' => [
|
'arsse_tags' => [
|
||||||
|
@ -369,17 +371,21 @@ trait SeriesSubscription {
|
||||||
'folder' => 3,
|
'folder' => 3,
|
||||||
'pinned' => false,
|
'pinned' => false,
|
||||||
'order_type' => 0,
|
'order_type' => 0,
|
||||||
|
'keep_rule' => "ook",
|
||||||
|
'block_rule' => "eek",
|
||||||
]);
|
]);
|
||||||
$state = $this->primeExpectations($this->data, [
|
$state = $this->primeExpectations($this->data, [
|
||||||
'arsse_feeds' => ['id','url','username','password','title'],
|
'arsse_feeds' => ['id','url','username','password','title'],
|
||||||
'arsse_subscriptions' => ['id','owner','feed','title','folder','pinned','order_type'],
|
'arsse_subscriptions' => ['id','owner','feed','title','folder','pinned','order_type','keep_rule','block_rule'],
|
||||||
]);
|
]);
|
||||||
$state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,"Ook Ook",3,0,0];
|
$state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,"Ook Ook",3,0,0,"ook","eek"];
|
||||||
$this->compareExpectations(static::$drv, $state);
|
$this->compareExpectations(static::$drv, $state);
|
||||||
Arsse::$db->subscriptionPropertiesSet($this->user, 1, [
|
Arsse::$db->subscriptionPropertiesSet($this->user, 1, [
|
||||||
'title' => null,
|
'title' => null,
|
||||||
|
'keep_rule' => null,
|
||||||
|
'block_rule' => null,
|
||||||
]);
|
]);
|
||||||
$state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0];
|
$state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0,null,null];
|
||||||
$this->compareExpectations(static::$drv, $state);
|
$this->compareExpectations(static::$drv, $state);
|
||||||
// making no changes is a valid result
|
// making no changes is a valid result
|
||||||
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['unhinged' => true]);
|
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['unhinged' => true]);
|
||||||
|
@ -395,30 +401,28 @@ trait SeriesSubscription {
|
||||||
$this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 3, ['folder' => null]));
|
$this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 3, ['folder' => null]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRenameASubscriptionToABlankTitle(): void {
|
/** @dataProvider provideInvalidSubscriptionProperties */
|
||||||
$this->assertException("missing", "Db", "ExceptionInput");
|
public function testSetThePropertiesOfASubscriptionToInvalidValues(array $data, string $exp): void {
|
||||||
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => ""]);
|
$this->assertException($exp, "Db", "ExceptionInput");
|
||||||
|
Arsse::$db->subscriptionPropertiesSet($this->user, 1, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRenameASubscriptionToAWhitespaceTitle(): void {
|
public function provideInvalidSubscriptionProperties(): iterable {
|
||||||
$this->assertException("whitespace", "Db", "ExceptionInput");
|
return [
|
||||||
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => " "]);
|
'Empty title' => [['title' => ""], "missing"],
|
||||||
}
|
'Whitespace title' => [['title' => " "], "whitespace"],
|
||||||
|
'Non-string title' => [['title' => []], "typeViolation"],
|
||||||
public function testRenameASubscriptionToFalse(): void {
|
'Non-string keep rule' => [['keep_rule' => 0], "typeViolation"],
|
||||||
$this->assertException("typeViolation", "Db", "ExceptionInput");
|
'Invalid keep rule' => [['keep_rule' => "*"], "invalidValue"],
|
||||||
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => false]);
|
'Non-string block rule' => [['block_rule' => 0], "typeViolation"],
|
||||||
|
'Invalid block rule' => [['block_rule' => "*"], "invalidValue"],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRenameASubscriptionToZero(): void {
|
public function testRenameASubscriptionToZero(): void {
|
||||||
$this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => 0]));
|
$this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => 0]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRenameASubscriptionToAnArray(): void {
|
|
||||||
$this->assertException("typeViolation", "Db", "ExceptionInput");
|
|
||||||
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => []]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSetThePropertiesOfAMissingSubscription(): void {
|
public function testSetThePropertiesOfAMissingSubscription(): void {
|
||||||
$this->assertException("subjectMissing", "Db", "ExceptionInput");
|
$this->assertException("subjectMissing", "Db", "ExceptionInput");
|
||||||
Arsse::$db->subscriptionPropertiesSet($this->user, 2112, ['folder' => null]);
|
Arsse::$db->subscriptionPropertiesSet($this->user, 2112, ['folder' => null]);
|
||||||
|
|
Loading…
Reference in a new issue