diff --git a/mailpoet/lib/API/JSON/v1/AutomatedLatestContent.php b/mailpoet/lib/API/JSON/v1/AutomatedLatestContent.php index b85a36c45d..f2332c11a5 100644 --- a/mailpoet/lib/API/JSON/v1/AutomatedLatestContent.php +++ b/mailpoet/lib/API/JSON/v1/AutomatedLatestContent.php @@ -5,6 +5,7 @@ namespace MailPoet\API\JSON\v1; use MailPoet\API\JSON\Endpoint as APIEndpoint; use MailPoet\Config\AccessControl; use MailPoet\Newsletter\AutomatedLatestContent as ALC; +use MailPoet\Newsletter\BlockPostQuery; use MailPoet\Util\APIPermissionHelper; use MailPoet\WP\Functions as WPFunctions; use MailPoet\WP\Posts as WPPosts; @@ -87,12 +88,12 @@ class AutomatedLatestContent extends APIEndpoint { public function getPosts($data = []) { return $this->successResponse( - $this->getPermittedPosts($this->ALC->getPosts($data)) + $this->getPermittedPosts($this->ALC->getPosts(new BlockPostQuery(['args' => $data]))) ); } public function getTransformedPosts($data = []) { - $posts = $this->getPermittedPosts($this->ALC->getPosts($data)); + $posts = $this->getPermittedPosts($this->ALC->getPosts(new BlockPostQuery(['args' => $data]))); return $this->successResponse( $this->ALC->transformPosts($data, $posts) ); @@ -103,7 +104,8 @@ class AutomatedLatestContent extends APIEndpoint { $renderedPosts = []; foreach ($data['blocks'] as $block) { - $posts = $this->getPermittedPosts($this->ALC->getPosts($block, $usedPosts)); + $query = new BlockPostQuery(['args' => $block, 'postsToExclude' => $usedPosts]); + $posts = $this->getPermittedPosts($this->ALC->getPosts($query)); $renderedPosts[] = $this->ALC->transformPosts($block, $posts); foreach ($posts as $post) { diff --git a/mailpoet/lib/Newsletter/AutomatedLatestContent.php b/mailpoet/lib/Newsletter/AutomatedLatestContent.php index ee07d4309b..71c225cff6 100644 --- a/mailpoet/lib/Newsletter/AutomatedLatestContent.php +++ b/mailpoet/lib/Newsletter/AutomatedLatestContent.php @@ -2,13 +2,11 @@ namespace MailPoet\Newsletter; -use DateTimeInterface; use MailPoet\Logging\LoggerFactory; use MailPoet\Newsletter\Editor\Transformer; use MailPoet\WP\Functions as WPFunctions; class AutomatedLatestContent { - const DEFAULT_POSTS_PER_PAGE = 10; /** @var LoggerFactory */ private $loggerFactory; @@ -47,66 +45,27 @@ class AutomatedLatestContent { $query->is_home = false; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps } - public function getPosts($args, $postsToExclude = [], $newsletterId = false, $newerThanTimestamp = false) { - $this->newsletterId = $newsletterId; + public function getPosts(BlockPostQuery $query) { + $this->newsletterId = $query->newsletterId; // Get posts as logged out user, so private posts hidden by other plugins (e.g. UAM) are also excluded $currentUserId = $this->wp->getCurrentUserId(); $this->wp->wpSetCurrentUser(0); $this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info( 'loading automated latest content', - ['args' => $args, 'posts_to_exclude' => $postsToExclude, 'newsletter_id' => $newsletterId, 'newer_than_timestamp' => $newerThanTimestamp] + [ + 'args' => $query->args, + 'posts_to_exclude' => $query->postsToExclude, + 'newsletter_id' => $query->newsletterId, + 'newer_than_timestamp' => $query->newerThanTimestamp, + ] ); - $postsPerPage = (!empty($args['amount']) && (int)$args['amount'] > 0) - ? (int)$args['amount'] - : self::DEFAULT_POSTS_PER_PAGE; - $parameters = [ - 'posts_per_page' => $postsPerPage, - 'post_type' => (isset($args['contentType'])) ? $args['contentType'] : 'post', - 'post_status' => (isset($args['postStatus'])) ? $args['postStatus'] : 'publish', - 'orderby' => 'date', - 'order' => isset($args['sortBy']) && in_array($args['sortBy'], ['newest', 'DESC']) ? 'DESC' : 'ASC', - ]; - if (!empty($args['offset']) && (int)$args['offset'] > 0) { - $parameters['offset'] = (int)$args['offset']; - } - if (isset($args['search'])) { - $parameters['s'] = $args['search']; - } - if (isset($args['posts']) && is_array($args['posts'])) { - $parameters['post__in'] = $args['posts']; - $parameters['posts_per_page'] = -1; // Get all posts with matching IDs - } - if (!empty($postsToExclude)) { - $parameters['post__not_in'] = $postsToExclude; - } - $parameters['tax_query'] = $this->constructTaxonomiesQuery($args); - - // WP posts with the type attachment have always post_status `inherit` - if ($parameters['post_type'] === 'attachment' && $parameters['post_status'] === 'publish') { - $parameters['post_status'] = 'inherit'; - } - - // This enables using posts query filters for get_posts, where by default - // it is disabled. - // However, it also enables other plugins and themes to hook in and alter - // the query. - $parameters['suppress_filters'] = false; - - if ($newerThanTimestamp instanceof DateTimeInterface) { - $parameters['date_query'] = [ - [ - 'column' => 'post_date', - 'after' => $newerThanTimestamp->format('Y-m-d H:i:s'), - ], - ]; - } // set low priority to execute 'ensureConstistentQueryType' before any other filter $filterPriority = defined('PHP_INT_MIN') ? constant('PHP_INT_MIN') : ~PHP_INT_MAX; $this->wp->addAction('pre_get_posts', [$this, 'ensureConsistentQueryType'], $filterPriority); - $this->_attachSentPostsFilter($newsletterId); - + $this->_attachSentPostsFilter($query->newsletterId); + $parameters = $query->getQueryParams(); $this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info( 'getting automated latest content', ['parameters' => $parameters] @@ -115,7 +74,7 @@ class AutomatedLatestContent { $this->logPosts($posts); $this->wp->removeAction('pre_get_posts', [$this, 'ensureConsistentQueryType'], $filterPriority); - $this->_detachSentPostsFilter($newsletterId); + $this->_detachSentPostsFilter($query->newsletterId); $this->wp->wpSetCurrentUser($currentUserId); return $posts; } @@ -125,43 +84,6 @@ class AutomatedLatestContent { return $transformer->transform($posts); } - public function constructTaxonomiesQuery($args) { - $taxonomiesQuery = []; - if (isset($args['terms']) && is_array($args['terms'])) { - $taxonomies = []; - // Categorize terms based on their taxonomies - foreach ($args['terms'] as $term) { - $taxonomy = $term['taxonomy']; - if (!isset($taxonomies[$taxonomy])) { - $taxonomies[$taxonomy] = []; - } - $taxonomies[$taxonomy][] = $term['id']; - } - - foreach ($taxonomies as $taxonomy => $terms) { - if (!empty($terms)) { - $tax = [ - 'taxonomy' => $taxonomy, - 'field' => 'id', - 'terms' => $terms, - ]; - if ($args['inclusionType'] === 'exclude') $tax['operator'] = 'NOT IN'; - $taxonomiesQuery[] = $tax; - } - } - if (!empty($taxonomiesQuery)) { - // With exclusion we want to use 'AND', because we want posts that - // don't have excluded tags/categories. But with inclusion we want to - // use 'OR', because we want posts that have any of the included - // tags/categories - $taxonomiesQuery['relation'] = ($args['inclusionType'] === 'exclude') ? 'AND' : 'OR'; - } - } - - // make $taxonomies_query nested to avoid conflicts with plugins that use taxonomies - return empty($taxonomiesQuery) ? [] : [$taxonomiesQuery]; - } - private function _attachSentPostsFilter($newsletterId) { if ($newsletterId > 0) { $this->wp->addAction('posts_where', [$this, 'filterOutSentPosts']); diff --git a/mailpoet/lib/Newsletter/BlockPostQuery.php b/mailpoet/lib/Newsletter/BlockPostQuery.php new file mode 100644 index 0000000000..032fa8d43e --- /dev/null +++ b/mailpoet/lib/Newsletter/BlockPostQuery.php @@ -0,0 +1,174 @@ + ['column' => 'post_date', 'after' => {date string}] + * + * @var bool|DateTimeInterface|null + */ + public $newerThanTimestamp = false; + + /** + * @param array{ + * args?: array{ + * amount?: int, + * offset?: int, + * posts?: int[], + * contentType?: string, + * postStatus?: string, + * search?: string, + * sortBy?: 'newest' | 'DESC' | 'ASC', + * terms?: array{'taxonomy': string, 'id': int}[], + * inclusionType?: 'include'|'exclude' + * }, + * postsToExclude?: int[], + * newsletterId?: int|false|null, + * newerThanTimestamp?: bool|DateTimeInterface|null, + * } $query + * @return void + */ + public function __construct( + array $query = [] + ) { + $this->args = $query['args'] ?? []; + $this->postsToExclude = $query['postsToExclude'] ?? []; + $this->newsletterId = $query['newsletterId'] ?? false; + $this->newerThanTimestamp = $query['newerThanTimestamp'] ?? false; + } + + public function getPostType(): string { + return $this->args['contentType'] ?? 'post'; + } + + /** + * Returns post status + * + * @return string + */ + public function getPostStatus(): string { + return $this->args['postStatus'] ?? 'publish'; + } + + public function getOrder(): string { + return isset($this->args['sortBy']) && in_array($this->args['sortBy'], ['newest', 'DESC']) ? 'DESC' : 'ASC'; + } + + /** + * @see https://developer.wordpress.org/reference/classes/wp_query/#taxonomy-parameters + * @return array[] array{relation: string, taxonomy: string, field: string, terms: int/string/array, operator: string} + */ + private function constructTaxonomiesQuery(): array { + $taxonomiesQuery = []; + if (isset($this->args['terms']) && is_array($this->args['terms'])) { + $taxonomies = []; + // Categorize terms based on their taxonomies + foreach ($this->args['terms'] as $term) { + $taxonomy = $term['taxonomy']; + if (!isset($taxonomies[$taxonomy])) { + $taxonomies[$taxonomy] = []; + } + $taxonomies[$taxonomy][] = $term['id']; + } + + foreach ($taxonomies as $taxonomy => $terms) { + if (!empty($terms)) { + $tax = [ + 'taxonomy' => $taxonomy, + 'field' => 'id', + 'terms' => $terms, + ]; + if (isset($this->args['inclusionType']) && $this->args['inclusionType'] === 'exclude') $tax['operator'] = 'NOT IN'; + $taxonomiesQuery[] = $tax; + } + } + if (!empty($taxonomiesQuery)) { + // With exclusion we want to use 'AND', because we want posts that + // don't have excluded tags/categories. But with inclusion we want to + // use 'OR', because we want posts that have any of the included + // tags/categories + $taxonomiesQuery['relation'] = (isset($this->args['inclusionType']) && $this->args['inclusionType'] === 'exclude') + ? 'AND' + : 'OR'; + } + } + + // make $taxonomies_query nested to avoid conflicts with plugins that use taxonomies + return empty($taxonomiesQuery) ? [] : [$taxonomiesQuery]; + } + + public function getQueryParams(): array { + $postsPerPage = (!empty($this->args['amount']) && (int)$this->args['amount'] > 0) + ? (int)$this->args['amount'] + : self::DEFAULT_POSTS_PER_PAGE; + $parameters = [ + 'posts_per_page' => $postsPerPage, + 'post_type' => $this->getPostType(), + 'post_status' => $this->getPostStatus(), + 'orderby' => 'date', + 'order' => $this->getOrder(), + ]; + if (!empty($this->args['offset']) && (int)$this->args['offset'] > 0) { + $parameters['offset'] = (int)$this->args['offset']; + } + if (isset($this->args['search'])) { + $parameters['s'] = $this->args['search']; + } + if (isset($this->args['posts']) && is_array($this->args['posts'])) { + $parameters['post__in'] = $this->args['posts']; + $parameters['posts_per_page'] = -1; // Get all posts with matching IDs + } + if (!empty($this->postsToExclude)) { + $parameters['post__not_in'] = $this->postsToExclude; + } + + // WP posts with the type attachment have always post_status `inherit` + if ($parameters['post_type'] === 'attachment' && $parameters['post_status'] === 'publish') { + $parameters['post_status'] = 'inherit'; + } + + // This enables using posts query filters for get_posts, where by default + // it is disabled. + // However, it also enables other plugins and themes to hook in and alter + // the query. + $parameters['suppress_filters'] = false; + + if ($this->newerThanTimestamp instanceof DateTimeInterface) { + $parameters['date_query'] = [ + [ + 'column' => 'post_date', + 'after' => $this->newerThanTimestamp->format('Y-m-d H:i:s'), + ], + ]; + } + + $parameters['tax_query'] = $this->constructTaxonomiesQuery(); + return $parameters; + } +} diff --git a/mailpoet/lib/Newsletter/Renderer/Blocks/AutomatedLatestContentBlock.php b/mailpoet/lib/Newsletter/Renderer/Blocks/AutomatedLatestContentBlock.php index 31123ff796..faaaf5286c 100644 --- a/mailpoet/lib/Newsletter/Renderer/Blocks/AutomatedLatestContentBlock.php +++ b/mailpoet/lib/Newsletter/Renderer/Blocks/AutomatedLatestContentBlock.php @@ -6,6 +6,7 @@ use MailPoet\Entities\NewsletterEntity; use MailPoet\Entities\NewsletterPostEntity; use MailPoet\Models\Newsletter; use MailPoet\Newsletter\AutomatedLatestContent; +use MailPoet\Newsletter\BlockPostQuery; use MailPoet\Newsletter\NewsletterPostsRepository; class AutomatedLatestContentBlock { @@ -47,7 +48,13 @@ class AutomatedLatestContentBlock { } } $postsToExclude = $this->getRenderedPosts((int)$newsletterId); - $aLCPosts = $this->ALC->getPosts($args, $postsToExclude, $newsletterId, $newerThanTimestamp); + $query = new BlockPostQuery([ + 'args' => $args, + 'postsToExclude' => $postsToExclude, + 'newsletterId' => $newsletterId, + 'newerThanTimestamp' => $newerThanTimestamp, + ]); + $aLCPosts = $this->ALC->getPosts($query); foreach ($aLCPosts as $post) { $postsToExclude[] = $post->ID; } diff --git a/mailpoet/tasks/phpstan/phpstan-7-baseline.neon b/mailpoet/tasks/phpstan/phpstan-7-baseline.neon index bb6b68b4cb..0835a3c250 100644 --- a/mailpoet/tasks/phpstan/phpstan-7-baseline.neon +++ b/mailpoet/tasks/phpstan/phpstan-7-baseline.neon @@ -674,7 +674,7 @@ parameters: - message: "#^Variable \\$terms in empty\\(\\) always exists and is not falsy\\.$#" count: 1 - path: ../../lib/Newsletter/AutomatedLatestContent.php + path: ../../lib/Newsletter/BlockPostQuery.php - message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#" diff --git a/mailpoet/tasks/phpstan/phpstan-8-baseline.neon b/mailpoet/tasks/phpstan/phpstan-8-baseline.neon index b693bcf63a..ddbd5cd4f1 100644 --- a/mailpoet/tasks/phpstan/phpstan-8-baseline.neon +++ b/mailpoet/tasks/phpstan/phpstan-8-baseline.neon @@ -674,7 +674,7 @@ parameters: - message: "#^Variable \\$terms in empty\\(\\) always exists and is not falsy\\.$#" count: 1 - path: ../../lib/Newsletter/AutomatedLatestContent.php + path: ../../lib/Newsletter/BlockPostQuery.php - message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#" diff --git a/mailpoet/tasks/phpstan/phpstan-8.1-baseline.neon b/mailpoet/tasks/phpstan/phpstan-8.1-baseline.neon index bdce5c721d..7fb4fd091a 100644 --- a/mailpoet/tasks/phpstan/phpstan-8.1-baseline.neon +++ b/mailpoet/tasks/phpstan/phpstan-8.1-baseline.neon @@ -688,7 +688,7 @@ parameters: - message: "#^Variable \\$terms in empty\\(\\) always exists and is not falsy\\.$#" count: 1 - path: ../../lib/Newsletter/AutomatedLatestContent.php + path: ../../lib/Newsletter/BlockPostQuery.php - message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#" diff --git a/mailpoet/tests/integration/Newsletter/AutomatedLatestContentTest.php b/mailpoet/tests/integration/Newsletter/AutomatedLatestContentTest.php index 0adff2e527..4b9ceef095 100644 --- a/mailpoet/tests/integration/Newsletter/AutomatedLatestContentTest.php +++ b/mailpoet/tests/integration/Newsletter/AutomatedLatestContentTest.php @@ -3,6 +3,7 @@ namespace MailPoet\Test\Newsletter; use MailPoet\Newsletter\AutomatedLatestContent; +use MailPoet\Newsletter\BlockPostQuery; class AutomatedLatestContentTest extends \MailPoetTest { /** @var AutomatedLatestContent */ @@ -32,7 +33,8 @@ class AutomatedLatestContentTest extends \MailPoetTest { 'inclusionType' => 'include', ]; - expect($this->alc->constructTaxonomiesQuery($args))->equals([ + $query = new BlockPostQuery(['args' => $args]); + expect($query->getQueryParams()['tax_query'])->equals([ [ [ 'taxonomy' => 'post_tag', @@ -64,7 +66,7 @@ class AutomatedLatestContentTest extends \MailPoetTest { 'inclusionType' => 'exclude', ]; - $query = $this->alc->constructTaxonomiesQuery($args); + $query = (new BlockPostQuery(['args' => $args]))->getQueryParams()['tax_query']; expect($query[0][0]['operator'])->equals('NOT IN'); expect($query[0]['relation'])->equals('AND');