diff --git a/CHANGELOG.md b/CHANGELOG.md index 2776abf..e9f3b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Post edit form**: Full CRUD for queue posts — edit message, reschedule, change status, re-queue failed posts - **Manual post creator**: New button in Post Queue toolbar to create manual cross-posts with article/service selection, custom message, and optional scheduling - **Scheduled posts**: Calendar picker for scheduling posts to specific date/time; scheduled_at shown in queue list +- **Dashboard trend chart**: Chart.js line chart showing daily posted vs failed counts between stat cards and service breakdown +- **Dashboard date range filter**: Period selector (7/30/90 days, all time) filters service breakdown, top articles, and trend chart +- **Hashtag placeholders**: `{tags}` (comma-separated) and `{hashtags}` (#-prefixed space-separated) template placeholders from article tags +- **Posts service filter**: SQL-driven service dropdown filter in posts list, plus search filter by article title or message content +- **CSV export**: "Export CSV" toolbar button on posts list to download filtered post data as CSV ### Added (original) diff --git a/src/packages/com_mokojoomcross/config.xml b/src/packages/com_mokojoomcross/config.xml index 1673dbd..3d35b9b 100644 --- a/src/packages/com_mokojoomcross/config.xml +++ b/src/packages/com_mokojoomcross/config.xml @@ -12,6 +12,18 @@ + + + + + Failed + + + + diff --git a/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.ini b/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.ini index e3c8076..d7d24b0 100644 --- a/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.ini +++ b/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.ini @@ -445,3 +445,47 @@ COM_MOKOJOOMCROSS_SETUP_STEP1="Choose a service type from the dropdown" COM_MOKOJOOMCROSS_SETUP_STEP2="Fill in the connection details that appear" COM_MOKOJOOMCROSS_SETUP_STEP3="For OAuth services, save first, then click Connect" COM_MOKOJOOMCROSS_SETUP_STEP4="Set status to Published and save" + +; Test Connection +COM_MOKOJOOMCROSS_TEST_CONNECTION_TITLE="Test Connection" +COM_MOKOJOOMCROSS_TEST_CONNECTION_DESC="Verify that your credentials are valid and the service is reachable." +COM_MOKOJOOMCROSS_TEST_CONNECTION_BUTTON="Test Connection" +COM_MOKOJOOMCROSS_TEST_CONNECTION_TESTING="Testing..." +COM_MOKOJOOMCROSS_TEST_CONNECTION_SUCCESS="Connection successful" +COM_MOKOJOOMCROSS_TEST_CONNECTION_FAILED="Connection failed" +COM_MOKOJOOMCROSS_TEST_CONNECTION_ERROR="Could not reach the server. Please try again." +COM_MOKOJOOMCROSS_TEST_CONNECTION_NO_SERVICE="No service specified for test." +COM_MOKOJOOMCROSS_TEST_CONNECTION_NOT_FOUND="Service record not found." +COM_MOKOJOOMCROSS_TEST_CONNECTION_NO_PLUGIN="No service plugin available for type '%s'." + +; Bulk Queue Actions +COM_MOKOJOOMCROSS_TOOLBAR_RETRY_FAILED="Retry Failed" +COM_MOKOJOOMCROSS_TOOLBAR_PURGE_POSTED="Purge Posted" +COM_MOKOJOOMCROSS_POSTS_N_RETRIED="%d failed post(s) re-queued for retry." +COM_MOKOJOOMCROSS_POSTS_N_RETRIED_1="1 failed post re-queued for retry." +COM_MOKOJOOMCROSS_POSTS_N_PURGED="%d posted record(s) purged." +COM_MOKOJOOMCROSS_POSTS_N_PURGED_1="1 posted record purged." + +; Queue Depth Warning +COM_MOKOJOOMCROSS_DASHBOARD_QUEUE_DEPTH_WARNING_TITLE="Large queue backlog" +COM_MOKOJOOMCROSS_DASHBOARD_QUEUE_DEPTH_WARNING="There are %d posts waiting in the queue. Please verify that the Joomla Task Scheduler is running and the MokoJoomCross scheduled task is enabled in System → Scheduled Tasks." + +; First-Publish-Only +COM_MOKOJOOMCROSS_CONFIG_FIRST_PUBLISH_ONLY="First Publish Only" +COM_MOKOJOOMCROSS_CONFIG_FIRST_PUBLISH_ONLY_DESC="When enabled, articles are only cross-posted on their first save as published. Subsequent edits to already-published articles will not trigger new cross-posts." + +; Trend Chart +COM_MOKOJOOMCROSS_DASHBOARD_TREND_CHART="Daily Post Trend" + +; Date Range Period Filter +COM_MOKOJOOMCROSS_PERIOD_7_DAYS="Last 7 days" +COM_MOKOJOOMCROSS_PERIOD_30_DAYS="Last 30 days" +COM_MOKOJOOMCROSS_PERIOD_90_DAYS="Last 90 days" +COM_MOKOJOOMCROSS_PERIOD_ALL_TIME="All time" + +; Hashtag Placeholders +COM_MOKOJOOMCROSS_PLACEHOLDER_TAGS="Article tags (comma-separated)" +COM_MOKOJOOMCROSS_PLACEHOLDER_HASHTAGS="Article tags as hashtags (#Tag1 #Tag2)" + +; CSV Export +COM_MOKOJOOMCROSS_EXPORT_CSV="Export CSV" diff --git a/src/packages/com_mokojoomcross/src/Controller/PostsController.php b/src/packages/com_mokojoomcross/src/Controller/PostsController.php index 79adc59..af8d7e3 100644 --- a/src/packages/com_mokojoomcross/src/Controller/PostsController.php +++ b/src/packages/com_mokojoomcross/src/Controller/PostsController.php @@ -13,7 +13,10 @@ namespace Joomla\Component\MokoJoomCross\Administrator\Controller; defined('_JEXEC') or die; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\AdminController; +use Joomla\CMS\Router\Route; class PostsController extends AdminController { @@ -21,4 +24,133 @@ class PostsController extends AdminController { return parent::getModel($name, $prefix, $config); } + + /** + * Re-queue all failed posts by resetting their status to queued and retry count to 0. + * + * @return void + */ + public function retryFailed(): void + { + $this->checkToken(); + + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('queued')) + ->set($db->quoteName('retry_count') . ' = 0') + ->set($db->quoteName('error_message') . ' = ' . $db->quote('')) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('status') . ' = ' . $db->quote('failed')); + + $db->setQuery($query); + $db->execute(); + + $count = $db->getAffectedRows(); + + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=posts', false), + Text::plural('COM_MOKOJOOMCROSS_POSTS_N_RETRIED', $count), + 'success' + ); + } + + /** + * Export posts as CSV download. + * + * @return void + */ + public function exportCsv(): void + { + $app = $this->app; + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select([ + $db->quoteName('c.title', 'article_title'), + 'CONCAT(' . $db->quoteName('s.title') . ', ' . $db->quote(' (') . ', ' + . $db->quoteName('s.service_type') . ', ' . $db->quote(')') . ') AS service', + $db->quoteName('a.status'), + $db->quoteName('a.message'), + $db->quoteName('a.posted_at'), + $db->quoteName('a.error_message'), + $db->quoteName('a.platform_post_id'), + $db->quoteName('a.created'), + ]) + ->from($db->quoteName('#__mokojoomcross_posts', 'a')) + ->join('LEFT', $db->quoteName('#__content', 'c') + . ' ON ' . $db->quoteName('c.id') . ' = ' . $db->quoteName('a.article_id')) + ->join('LEFT', $db->quoteName('#__mokojoomcross_services', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('a.service_id')) + ->order($db->quoteName('a.created') . ' DESC'); + + // Apply current filters + $status = $app->input->get('filter_status', '', 'string'); + + if (!empty($status)) { + $query->where($db->quoteName('a.status') . ' = ' . $db->quote($status)); + } + + $serviceId = $app->input->getInt('filter_service_id', 0); + + if (!empty($serviceId)) { + $query->where($db->quoteName('a.service_id') . ' = ' . (int) $serviceId); + } + + $search = $app->input->get('filter_search', '', 'string'); + + if (!empty($search)) { + $search = '%' . $db->escape(trim($search), true) . '%'; + $query->where('(' . $db->quoteName('c.title') . ' LIKE ' . $db->quote($search) + . ' OR ' . $db->quoteName('a.message') . ' LIKE ' . $db->quote($search) . ')'); + } + + $db->setQuery($query); + $rows = $db->loadAssocList() ?: []; + + $filename = 'mokojoomcross-posts-' . Factory::getDate()->format('Y-m-d') . '.csv'; + + $app->setHeader('Content-Type', 'text/csv; charset=utf-8'); + $app->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); + $app->sendHeaders(); + + $fp = fopen('php://output', 'w'); + fputcsv($fp, ['Article', 'Service', 'Status', 'Message', 'Posted At', 'Error', 'Platform Post ID', 'Created']); + + foreach ($rows as $row) { + fputcsv($fp, $row); + } + + fclose($fp); + + $app->close(); + } + + /** + * Purge (delete) all posts with status 'posted'. + * + * @return void + */ + public function purgePosted(): void + { + $this->checkToken(); + + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->delete($db->quoteName('#__mokojoomcross_posts')) + ->where($db->quoteName('status') . ' = ' . $db->quote('posted')); + + $db->setQuery($query); + $db->execute(); + + $count = $db->getAffectedRows(); + + $this->setRedirect( + Route::_('index.php?option=com_mokojoomcross&view=posts', false), + Text::plural('COM_MOKOJOOMCROSS_POSTS_N_PURGED', $count), + 'success' + ); + } } diff --git a/src/packages/com_mokojoomcross/src/Controller/ServiceController.php b/src/packages/com_mokojoomcross/src/Controller/ServiceController.php index e9a3258..e3491d8 100644 --- a/src/packages/com_mokojoomcross/src/Controller/ServiceController.php +++ b/src/packages/com_mokojoomcross/src/Controller/ServiceController.php @@ -13,8 +13,81 @@ namespace Joomla\Component\MokoJoomCross\Administrator\Controller; defined('_JEXEC') or die; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\FormController; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Response\JsonResponse; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; class ServiceController extends FormController { + /** + * Test connection to a service by validating its credentials. + * + * @return void + */ + public function testConnection(): void + { + $app = $this->app; + $id = (int) $this->input->getInt('id', 0); + + try { + if ($id <= 0) { + throw new \RuntimeException(Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_NO_SERVICE')); + } + + // Load the service record + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokojoomcross_services')) + ->where($db->quoteName('id') . ' = ' . $id); + $db->setQuery($query); + $service = $db->loadObject(); + + if (!$service) { + throw new \RuntimeException(Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_NOT_FOUND')); + } + + // Get service plugins via dispatcher + PluginHelper::importPlugin('mokojoomcross'); + + $servicePlugins = []; + $app->getDispatcher()->dispatch( + 'onMokoJoomCrossGetServices', + new \Joomla\Event\Event('onMokoJoomCrossGetServices', [&$servicePlugins]) + ); + + // Find the matching plugin + $plugin = null; + + foreach ($servicePlugins as $sp) { + if ($sp instanceof MokoJoomCrossServiceInterface && $sp->getServiceType() === $service->service_type) { + $plugin = $sp; + break; + } + } + + if (!$plugin) { + throw new \RuntimeException(Text::sprintf('COM_MOKOJOOMCROSS_TEST_CONNECTION_NO_PLUGIN', $service->service_type)); + } + + // Decode credentials and validate + $credentials = json_decode($service->credentials ?: '{}', true) ?: []; + $result = $plugin->validateCredentials($credentials); + + $app->mimeType = 'application/json'; + $app->setHeader('Content-Type', 'application/json; charset=utf-8'); + + echo new JsonResponse($result); + } catch (\Throwable $e) { + $app->mimeType = 'application/json'; + $app->setHeader('Content-Type', 'application/json; charset=utf-8'); + + echo new JsonResponse($e); + } + + $app->close(); + } } diff --git a/src/packages/com_mokojoomcross/src/Helper/QueueProcessor.php b/src/packages/com_mokojoomcross/src/Helper/QueueProcessor.php index 2fcc1f5..1c7733a 100644 --- a/src/packages/com_mokojoomcross/src/Helper/QueueProcessor.php +++ b/src/packages/com_mokojoomcross/src/Helper/QueueProcessor.php @@ -71,9 +71,8 @@ class QueueProcessor $db->setQuery($query); $queuedPosts = $db->loadObjectList() ?: []; - // 2. Process failed posts eligible for retry - $retryAfter = Factory::getDate('now - ' . $retryDelay . ' seconds')->toSql(); - + // 2. Process failed posts eligible for retry (exponential backoff) + // Retry 1 waits retryDelay, retry 2 waits retryDelay*2, retry 3 waits retryDelay*4, etc. $query = $db->getQuery(true) ->select('p.*, s.service_type, s.credentials, s.params AS service_params') ->from($db->quoteName('#__mokojoomcross_posts', 'p')) @@ -81,7 +80,8 @@ class QueueProcessor . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')) ->where($db->quoteName('p.status') . ' = ' . $db->quote('failed')) ->where($db->quoteName('p.retry_count') . ' < ' . $maxRetry) - ->where($db->quoteName('p.modified') . ' <= ' . $db->quote($retryAfter)) + ->where($db->quoteName('p.modified') . ' <= DATE_SUB(NOW(), INTERVAL (' + . (int) $retryDelay . ' * POW(2, ' . $db->quoteName('p.retry_count') . ')) SECOND)') ->where($db->quoteName('s.published') . ' = 1') ->order($db->quoteName('p.modified') . ' ASC') ->setLimit($batchSize); @@ -420,6 +420,27 @@ class QueueProcessor $introImage = \Joomla\CMS\Uri\Uri::root() . ltrim($images->image_intro, '/'); } + // Resolve article tags + $tagNames = []; + + if (!empty($article->id)) { + $query = $db->getQuery(true) + ->select($db->quoteName('t.title')) + ->from($db->quoteName('#__tags', 't')) + ->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm') + . ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id')) + ->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article')) + ->where($db->quoteName('m.content_item_id') . ' = ' . (int) $article->id) + ->where($db->quoteName('t.published') . ' = 1'); + $db->setQuery($query); + $tagNames = $db->loadColumn() ?: []; + } + + $tagsComma = implode(', ', $tagNames); + $hashtags = implode(' ', array_map(function ($tag) { + return '#' . preg_replace('/\s+/', '', $tag); + }, $tagNames)); + $replacements = [ '{title}' => $article->title ?? '', '{introtext}' => strip_tags(mb_substr($article->introtext ?? '', 0, 280)), @@ -429,6 +450,8 @@ class QueueProcessor '{category}' => $categoryName, '{author}' => $authorName, '{date}' => Factory::getDate($article->publish_up ?? 'now')->format('Y-m-d'), + '{tags}' => $tagsComma, + '{hashtags}' => $hashtags, ]; return str_replace(array_keys($replacements), array_values($replacements), $template); diff --git a/src/packages/com_mokojoomcross/src/Model/DashboardModel.php b/src/packages/com_mokojoomcross/src/Model/DashboardModel.php index fcc0dac..b5e30c2 100644 --- a/src/packages/com_mokojoomcross/src/Model/DashboardModel.php +++ b/src/packages/com_mokojoomcross/src/Model/DashboardModel.php @@ -97,9 +97,11 @@ class DashboardModel extends BaseDatabaseModel /** * Get posts-per-service breakdown for the analytics chart. * + * @param string|null $since Only count posts created on or after this datetime + * * @return array [['service_type' => '...', 'posted' => N, 'failed' => N, 'queued' => N], ...] */ - public function getServiceBreakdown(): array + public function getServiceBreakdown(?string $since = null): array { $db = $this->getDatabase(); @@ -118,6 +120,10 @@ class DashboardModel extends BaseDatabaseModel ->group($db->quoteName(['s.id', 's.service_type', 's.title'])) ->order('total DESC'); + if ($since !== null) { + $query->where($db->quoteName('p.created') . ' >= ' . $db->quote($since)); + } + $db->setQuery($query); return $db->loadAssocList() ?: []; @@ -156,11 +162,12 @@ class DashboardModel extends BaseDatabaseModel /** * Get most cross-posted articles. * - * @param int $limit Number of articles + * @param int $limit Number of articles + * @param string|null $since Only count posts created on or after this datetime * * @return array */ - public function getTopArticles(int $limit = 5): array + public function getTopArticles(int $limit = 5, ?string $since = null): array { $db = $this->getDatabase(); @@ -177,6 +184,10 @@ class DashboardModel extends BaseDatabaseModel ->group($db->quoteName(['c.id', 'c.title'])) ->order('post_count DESC'); + if ($since !== null) { + $query->where($db->quoteName('p.created') . ' >= ' . $db->quote($since)); + } + $db->setQuery($query, 0, $limit); return $db->loadAssocList() ?: []; diff --git a/src/packages/com_mokojoomcross/src/Model/PostsModel.php b/src/packages/com_mokojoomcross/src/Model/PostsModel.php index 2260a41..7168618 100644 --- a/src/packages/com_mokojoomcross/src/Model/PostsModel.php +++ b/src/packages/com_mokojoomcross/src/Model/PostsModel.php @@ -65,6 +65,22 @@ class PostsModel extends ListModel $query->where($db->quoteName('a.status') . ' = ' . $db->quote($status)); } + // Filter by service + $serviceId = $this->getState('filter.service_id'); + + if (!empty($serviceId)) { + $query->where($db->quoteName('a.service_id') . ' = ' . (int) $serviceId); + } + + // Filter by search (article title or message content) + $search = $this->getState('filter.search'); + + if (!empty($search)) { + $search = '%' . $db->escape(trim($search), true) . '%'; + $query->where('(' . $db->quoteName('c.title') . ' LIKE ' . $db->quote($search) + . ' OR ' . $db->quoteName('a.message') . ' LIKE ' . $db->quote($search) . ')'); + } + // Ordering $orderCol = $this->state->get('list.ordering', 'a.created'); $orderDirn = $this->state->get('list.direction', 'DESC'); diff --git a/src/packages/com_mokojoomcross/src/View/Dashboard/HtmlView.php b/src/packages/com_mokojoomcross/src/View/Dashboard/HtmlView.php index 92a62bc..4d36f03 100644 --- a/src/packages/com_mokojoomcross/src/View/Dashboard/HtmlView.php +++ b/src/packages/com_mokojoomcross/src/View/Dashboard/HtmlView.php @@ -13,6 +13,7 @@ namespace Joomla\Component\MokoJoomCross\Administrator\View\Dashboard; defined('_JEXEC') or die; +use Joomla\CMS\Factory; use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; use Joomla\CMS\Toolbar\ToolbarHelper; use Joomla\Component\MokoJoomCross\Administrator\Helper\MokoJoomCrossHelper; @@ -26,17 +27,33 @@ class HtmlView extends BaseHtmlView protected $dailyTrend; protected $topArticles; public $sidebar; + public $period; public function display($tpl = null): void { $model = $this->getModel(); + // Read period parameter for date range filtering + $this->period = Factory::getApplication()->input->getInt('period', 30); + $validPeriods = [7, 30, 90, 0]; + + if (!in_array($this->period, $validPeriods, true)) { + $this->period = 30; + } + + // Calculate the since date based on period (0 = all time) + $since = null; + + if ($this->period > 0) { + $since = Factory::getDate('now - ' . $this->period . ' days')->toSql(); + } + $this->stats = $this->get('Stats'); $this->migrationAvailable = $this->get('MigrationAvailable'); $this->recentActivity = $model->getRecentActivity(10); - $this->serviceBreakdown = $model->getServiceBreakdown(); - $this->dailyTrend = $model->getDailyTrend(14); - $this->topArticles = $model->getTopArticles(5); + $this->serviceBreakdown = $model->getServiceBreakdown($since); + $this->dailyTrend = $model->getDailyTrend($this->period ?: 365); + $this->topArticles = $model->getTopArticles(5, $since); $this->addToolbar(); diff --git a/src/packages/com_mokojoomcross/src/View/Posts/HtmlView.php b/src/packages/com_mokojoomcross/src/View/Posts/HtmlView.php index cd814c2..9c7d12c 100644 --- a/src/packages/com_mokojoomcross/src/View/Posts/HtmlView.php +++ b/src/packages/com_mokojoomcross/src/View/Posts/HtmlView.php @@ -45,10 +45,26 @@ class HtmlView extends BaseHtmlView { ToolbarHelper::title('MokoJoomCross — Post Queue', 'share-alt'); ToolbarHelper::addNew('post.add'); + + $toolbar = Toolbar::getInstance('toolbar'); + $toolbar->standardButton('retry', 'COM_MOKOJOOMCROSS_TOOLBAR_RETRY_FAILED', 'posts.retryFailed') + ->icon('icon-refresh') + ->listCheck(false); + $toolbar->standardButton('purge', 'COM_MOKOJOOMCROSS_TOOLBAR_PURGE_POSTED', 'posts.purgePosted') + ->icon('icon-trash') + ->listCheck(false); + ToolbarHelper::deleteList('', 'posts.delete', 'JTOOLBAR_DELETE'); + // Export CSV button + $toolbar->appendButton( + 'Link', + 'download', + 'COM_MOKOJOOMCROSS_EXPORT_CSV', + Route::_('index.php?option=com_mokojoomcross&task=posts.exportCsv&format=raw', false) + ); + // Dashboard link in toolbar - $toolbar = Toolbar::getInstance('toolbar'); $toolbar->appendButton( 'Link', 'home', diff --git a/src/packages/com_mokojoomcross/tmpl/dashboard/default.php b/src/packages/com_mokojoomcross/tmpl/dashboard/default.php index 443ffde..9901a0b 100644 --- a/src/packages/com_mokojoomcross/tmpl/dashboard/default.php +++ b/src/packages/com_mokojoomcross/tmpl/dashboard/default.php @@ -30,6 +30,16 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler'); +queued_count > 50) : ?> +
+ +
+
+ queued_count); ?> +
+
+ +
@@ -67,6 +77,71 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler');
+ + dailyTrend)) : ?> +
+
+
+
+ + + +
+
+
+ +
+
+ + + migrationAvailable) : ?>

diff --git a/src/packages/com_mokojoomcross/tmpl/service/edit.php b/src/packages/com_mokojoomcross/tmpl/service/edit.php index 60636ed..3c551b0 100644 --- a/src/packages/com_mokojoomcross/tmpl/service/edit.php +++ b/src/packages/com_mokojoomcross/tmpl/service/edit.php @@ -142,6 +142,71 @@ $helpAlias = $helpArticles[$serviceType] ?? '';
+ + 0 && !empty($serviceType)) : ?> +
+
+
+ + +
+
+
+

+ + +
+
+ + diff --git a/src/packages/plg_system_mokojoomcross/src/Extension/MokoJoomCross.php b/src/packages/plg_system_mokojoomcross/src/Extension/MokoJoomCross.php index 55d7c65..dbc3c40 100644 --- a/src/packages/plg_system_mokojoomcross/src/Extension/MokoJoomCross.php +++ b/src/packages/plg_system_mokojoomcross/src/Extension/MokoJoomCross.php @@ -137,6 +137,11 @@ class MokoJoomCross extends CMSPlugin implements SubscriberInterface return; } + // First-publish-only: skip cross-posting if the article is being updated (not new) + if ($componentParams->get('post_on_first_publish_only', 0) && !$isNew) { + return; + } + $this->dispatchCrossPost($article); } @@ -412,6 +417,27 @@ class MokoJoomCross extends CMSPlugin implements SubscriberInterface $introImage = Uri::root() . ltrim($images->image_intro, '/'); } + // Resolve article tags + $tagNames = []; + + if (!empty($article->id)) { + $query = $db->getQuery(true) + ->select($db->quoteName('t.title')) + ->from($db->quoteName('#__tags', 't')) + ->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm') + . ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id')) + ->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article')) + ->where($db->quoteName('m.content_item_id') . ' = ' . (int) $article->id) + ->where($db->quoteName('t.published') . ' = 1'); + $db->setQuery($query); + $tagNames = $db->loadColumn() ?: []; + } + + $tagsComma = implode(', ', $tagNames); + $hashtags = implode(' ', array_map(function ($tag) { + return '#' . preg_replace('/\s+/', '', $tag); + }, $tagNames)); + // Replace placeholders $replacements = [ '{title}' => $article->title ?? '', @@ -422,6 +448,8 @@ class MokoJoomCross extends CMSPlugin implements SubscriberInterface '{category}' => $categoryName, '{author}' => $authorName, '{date}' => Factory::getDate($article->publish_up ?? 'now')->format('Y-m-d'), + '{tags}' => $tagsComma, + '{hashtags}' => $hashtags, ]; return str_replace(array_keys($replacements), array_values($replacements), $template);