diff --git a/CHANGELOG.md b/CHANGELOG.md index 66d9f92..3b18fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,3 +26,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Service edit form with default/custom mode toggle and credential fields - Dashboard migration controller action for Perfect Publisher Pro import - Template placeholders: {title}, {url}, {introtext}, {fulltext}, {image}, {category}, {author}, {date} +- Queue processing: Joomla Scheduled Task plugin (`plg_task_mokojoomcross`) — preferred method +- Queue processing: Page-load fallback via system plugin `onAfterRender` with configurable throttle +- Configurable processing method: scheduler-only (recommended), page-load only, or both +- Dashboard warning banner when page-load processing is active instead of scheduler +- Shared `QueueProcessor` helper with DB lock to prevent concurrent execution +- Failed post retry with configurable max retries and delay +- Automatic log cleanup based on configurable retention period diff --git a/src/packages/com_mokojoomcross/config.xml b/src/packages/com_mokojoomcross/config.xml index fa5dfa3..5c8d967 100644 --- a/src/packages/com_mokojoomcross/config.xml +++ b/src/packages/com_mokojoomcross/config.xml @@ -51,4 +51,40 @@ rows="4" /> + +
+ + + + + + + + + + + + + +
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 d8ab086..5bdeaa6 100644 --- a/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.ini +++ b/src/packages/com_mokojoomcross/language/en-GB/com_mokojoomcross.ini @@ -73,3 +73,20 @@ COM_MOKOJOOMCROSS_HEADING_MODE="Mode" COM_MOKOJOOMCROSS_DASHBOARD_RECENT_ACTIVITY="Recent Activity" COM_MOKOJOOMCROSS_DASHBOARD_NO_RECENT="No recent activity." COM_MOKOJOOMCROSS_DASHBOARD_TOTAL_POSTS="Total Posts" +COM_MOKOJOOMCROSS_DASHBOARD_PAGELOAD_WARNING_TITLE="Page-load queue processing is active" +COM_MOKOJOOMCROSS_DASHBOARD_PAGELOAD_WARNING="You are using page-load processing for the cross-post queue. This is a fallback method and may be unreliable on low-traffic sites. For production use, switch to Joomla Scheduled Tasks: create a task of type MokoJoomCross - Process Queue in System → Scheduled Tasks, then set queue processing to Scheduler only in component options." + +; Queue Processing Configuration +COM_MOKOJOOMCROSS_CONFIG_QUEUE="Queue Processing" +COM_MOKOJOOMCROSS_CONFIG_QUEUE_PROCESSING="Processing Method" +COM_MOKOJOOMCROSS_CONFIG_QUEUE_PROCESSING_DESC="How queued posts, retries, and scheduled posts are processed. Scheduler (recommended) uses Joomla's built-in Task Scheduler. Page-load piggybacks on page requests." +COM_MOKOJOOMCROSS_CONFIG_QUEUE_SCHEDULER="Scheduler only (recommended)" +COM_MOKOJOOMCROSS_CONFIG_QUEUE_PAGELOAD="Page-load only (fallback)" +COM_MOKOJOOMCROSS_CONFIG_QUEUE_BOTH="Both (scheduler + page-load)" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_CLIENT="Page-load Client" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_CLIENT_DESC="Which Joomla application triggers page-load processing." +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_BOTH="Backend and Frontend" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_ADMIN="Backend only" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_SITE="Frontend only" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_INTERVAL="Page-load Interval (seconds)" +COM_MOKOJOOMCROSS_CONFIG_PAGELOAD_INTERVAL_DESC="Minimum seconds between page-load queue runs. Lower = more responsive but more DB queries per page load." diff --git a/src/packages/com_mokojoomcross/src/Helper/QueueProcessor.php b/src/packages/com_mokojoomcross/src/Helper/QueueProcessor.php new file mode 100644 index 0000000..57ac9c8 --- /dev/null +++ b/src/packages/com_mokojoomcross/src/Helper/QueueProcessor.php @@ -0,0 +1,371 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Component\MokoJoomCross\Administrator\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface; + +/** + * Shared queue processor used by: + * - System plugin onAfterRender (page-load processing) + * - Task scheduler plugin (Joomla scheduled task) + * + * Handles: queued posts, failed retries, scheduled posts, and log cleanup. + * Uses a simple DB-based lock to prevent concurrent execution. + */ +class QueueProcessor +{ + /** + * Process the post queue: dispatch queued posts, retry failed, fire scheduled. + * + * @param int $batchSize Max posts to process per run + * + * @return array ['processed' => int, 'succeeded' => int, 'failed' => int, 'skipped' => int] + */ + public static function processQueue(int $batchSize = 10): array + { + $result = ['processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0]; + + if (!self::acquireLock()) { + $result['skipped'] = -1; + + return $result; + } + + try { + $db = Factory::getDbo(); + $componentParams = ComponentHelper::getParams('com_mokojoomcross'); + $maxRetry = (int) $componentParams->get('retry_max', 3); + $retryDelay = (int) $componentParams->get('retry_delay', 300); + $now = Factory::getDate()->toSql(); + + // Build service plugin map + $pluginMap = self::getServicePluginMap(); + + // 1. Process queued posts + $query = $db->getQuery(true) + ->select('p.*, s.service_type, s.credentials, s.params AS service_params') + ->from($db->quoteName('#__mokojoomcross_posts', 'p')) + ->join('INNER', $db->quoteName('#__mokojoomcross_services', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id')) + ->where($db->quoteName('p.status') . ' = ' . $db->quote('queued')) + ->where('(' . $db->quoteName('p.scheduled_at') . ' IS NULL OR ' + . $db->quoteName('p.scheduled_at') . ' <= ' . $db->quote($now) . ')') + ->where($db->quoteName('s.published') . ' = 1') + ->order($db->quoteName('p.created') . ' ASC') + ->setLimit($batchSize); + + $db->setQuery($query); + $queuedPosts = $db->loadObjectList() ?: []; + + // 2. Process failed posts eligible for retry + $retryAfter = Factory::getDate('now - ' . $retryDelay . ' seconds')->toSql(); + + $query = $db->getQuery(true) + ->select('p.*, s.service_type, s.credentials, s.params AS service_params') + ->from($db->quoteName('#__mokojoomcross_posts', 'p')) + ->join('INNER', $db->quoteName('#__mokojoomcross_services', 's') + . ' 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('s.published') . ' = 1') + ->order($db->quoteName('p.modified') . ' ASC') + ->setLimit($batchSize); + + $db->setQuery($query); + $retryPosts = $db->loadObjectList() ?: []; + + $allPosts = array_merge($queuedPosts, $retryPosts); + + foreach ($allPosts as $post) { + $result['processed']++; + + $plugin = $pluginMap[$post->service_type] ?? null; + + if (!$plugin) { + $result['skipped']++; + continue; + } + + $isRetry = ($post->status === 'failed'); + + if ($isRetry) { + // Increment retry count + $newRetryCount = (int) $post->retry_count + 1; + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('retry_count') . ' = ' . $newRetryCount) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + } + + // Mark as posting + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('posting')) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + $credentials = json_decode($post->credentials ?: '{}', true) ?: []; + $params = json_decode($post->service_params ?: '{}', true) ?: []; + + try { + $apiResult = $plugin->publish($post->message, [], $credentials, $params); + + if (!empty($apiResult['success'])) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('posted')) + ->set($db->quoteName('platform_post_id') . ' = ' . $db->quote($apiResult['platform_post_id'] ?? '')) + ->set($db->quoteName('platform_response') . ' = ' . $db->quote(json_encode($apiResult['response'] ?? []))) + ->set($db->quoteName('posted_at') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + self::log($db, (int) $post->id, (int) $post->service_id, 'info', + sprintf('%s to %s (ID: %s)', $isRetry ? 'Retry succeeded' : 'Posted', $post->service_type, $apiResult['platform_post_id'] ?? 'n/a')); + + $result['succeeded']++; + } else { + $errorMsg = $apiResult['response']['error'] ?? json_encode($apiResult['response'] ?? []); + + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('failed')) + ->set($db->quoteName('error_message') . ' = ' . $db->quote(mb_substr($errorMsg, 0, 1000))) + ->set($db->quoteName('platform_response') . ' = ' . $db->quote(json_encode($apiResult['response'] ?? []))) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + self::log($db, (int) $post->id, (int) $post->service_id, 'error', + sprintf('Failed %s: %s', $post->service_type, mb_substr($errorMsg, 0, 500))); + + $result['failed']++; + } + } catch (\Throwable $e) { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__mokojoomcross_posts')) + ->set($db->quoteName('status') . ' = ' . $db->quote('failed')) + ->set($db->quoteName('error_message') . ' = ' . $db->quote(mb_substr($e->getMessage(), 0, 1000))) + ->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where($db->quoteName('id') . ' = ' . (int) $post->id) + ); + $db->execute(); + + self::log($db, (int) $post->id, (int) $post->service_id, 'error', + sprintf('Exception %s: %s', $post->service_type, mb_substr($e->getMessage(), 0, 500))); + + $result['failed']++; + } + } + + // 3. Clean up old logs + self::cleanupLogs($db, $componentParams); + + } finally { + self::releaseLock(); + } + + return $result; + } + + /** + * Check if there are pending items in the queue. + * + * @return bool + */ + public static function hasPendingWork(): bool + { + $db = Factory::getDbo(); + + $componentParams = ComponentHelper::getParams('com_mokojoomcross'); + $maxRetry = (int) $componentParams->get('retry_max', 3); + $retryDelay = (int) $componentParams->get('retry_delay', 300); + $retryAfter = Factory::getDate('now - ' . $retryDelay . ' seconds')->toSql(); + $now = Factory::getDate()->toSql(); + + // Queued posts ready to go + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomcross_posts')) + ->where($db->quoteName('status') . ' = ' . $db->quote('queued')) + ->where('(' . $db->quoteName('scheduled_at') . ' IS NULL OR ' + . $db->quoteName('scheduled_at') . ' <= ' . $db->quote($now) . ')'); + $db->setQuery($query); + $queued = (int) $db->loadResult(); + + // Failed posts eligible for retry + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoomcross_posts')) + ->where($db->quoteName('status') . ' = ' . $db->quote('failed')) + ->where($db->quoteName('retry_count') . ' < ' . $maxRetry) + ->where($db->quoteName('modified') . ' <= ' . $db->quote($retryAfter)); + $db->setQuery($query); + $retryable = (int) $db->loadResult(); + + return ($queued + $retryable) > 0; + } + + /** + * Import mokojoomcross plugins and build a type → plugin instance map. + * + * @return array + */ + private static function getServicePluginMap(): array + { + PluginHelper::importPlugin('mokojoomcross'); + + $servicePlugins = []; + + try { + Factory::getApplication()->getDispatcher()->dispatch( + 'onMokoJoomCrossGetServices', + new \Joomla\Event\Event('onMokoJoomCrossGetServices', [&$servicePlugins]) + ); + } catch (\Throwable $e) { + // Dispatcher may not be available in all contexts + } + + $map = []; + + foreach ($servicePlugins as $plugin) { + if ($plugin instanceof MokoJoomCrossServiceInterface) { + $map[$plugin->getServiceType()] = $plugin; + } + } + + return $map; + } + + /** + * Delete logs older than the configured retention period. + */ + private static function cleanupLogs($db, $componentParams): void + { + $retentionDays = (int) $componentParams->get('log_retention_days', 90); + + if ($retentionDays <= 0) { + return; + } + + $cutoff = Factory::getDate('now - ' . $retentionDays . ' days')->toSql(); + + $query = $db->getQuery(true) + ->delete($db->quoteName('#__mokojoomcross_logs')) + ->where($db->quoteName('created') . ' < ' . $db->quote($cutoff)); + + $db->setQuery($query); + $db->execute(); + } + + /** + * Simple DB-based lock to prevent concurrent queue processing. + */ + private static function acquireLock(): bool + { + $db = Factory::getDbo(); + + // Use component params as lock storage + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokojoomcross')); + + $db->setQuery($query); + $params = json_decode($db->loadResult() ?: '{}', true) ?: []; + + $lockTime = $params['_queue_lock'] ?? 0; + + // Lock expires after 120 seconds (safety valve for crashed processes) + if ($lockTime > 0 && (time() - $lockTime) < 120) { + return false; + } + + $params['_queue_lock'] = time(); + + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params))) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokojoomcross')); + + $db->setQuery($query); + $db->execute(); + + return true; + } + + /** + * Release the processing lock. + */ + private static function releaseLock(): void + { + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokojoomcross')); + + $db->setQuery($query); + $params = json_decode($db->loadResult() ?: '{}', true) ?: []; + + unset($params['_queue_lock']); + + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params))) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokojoomcross')); + + $db->setQuery($query); + $db->execute(); + } + + /** + * Write a log entry. + */ + private static function log($db, ?int $postId, ?int $serviceId, string $level, string $message): void + { + $log = (object) [ + 'post_id' => $postId, + 'service_id' => $serviceId, + 'level' => $level, + 'message' => mb_substr($message, 0, 2000), + 'context' => '{}', + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokojoomcross_logs', $log); + } +} diff --git a/src/packages/com_mokojoomcross/tmpl/dashboard/default.php b/src/packages/com_mokojoomcross/tmpl/dashboard/default.php index 21ac900..7a81852 100644 --- a/src/packages/com_mokojoomcross/tmpl/dashboard/default.php +++ b/src/packages/com_mokojoomcross/tmpl/dashboard/default.php @@ -11,12 +11,25 @@ defined('_JEXEC') or die; +use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Language\Text; use Joomla\CMS\Router\Route; /** @var \Joomla\Component\MokoJoomCross\Administrator\View\Dashboard\HtmlView $this */ $stats = $this->stats; +$componentParams = ComponentHelper::getParams('com_mokojoomcross'); +$queueProcessing = $componentParams->get('queue_processing', 'scheduler'); ?> + +
+ +
+
+ +
+
+ +
diff --git a/src/packages/plg_system_mokojoomcross/src/Extension/MokoJoomCross.php b/src/packages/plg_system_mokojoomcross/src/Extension/MokoJoomCross.php index 98b1e2a..b02a39d 100644 --- a/src/packages/plg_system_mokojoomcross/src/Extension/MokoJoomCross.php +++ b/src/packages/plg_system_mokojoomcross/src/Extension/MokoJoomCross.php @@ -42,9 +42,82 @@ class MokoJoomCross extends CMSPlugin implements SubscriberInterface return [ 'onContentAfterSave' => 'onContentAfterSave', 'onContentChangeState' => 'onContentChangeState', + 'onAfterRender' => 'onAfterRender', ]; } + /** + * Process queued posts on page load (backend and/or frontend). + * + * Only runs if page-load processing is enabled in component config, + * and only once per throttle interval (default 5 minutes). + */ + public function onAfterRender(): void + { + $componentParams = ComponentHelper::getParams('com_mokojoomcross'); + $processingMode = $componentParams->get('queue_processing', 'scheduler'); + + if ($processingMode !== 'pageload' && $processingMode !== 'both') { + return; + } + + $app = $this->getApplication(); + + $pageloadClient = $componentParams->get('pageload_client', 'both'); + + if ($pageloadClient === 'admin' && !$app->isClient('administrator')) { + return; + } + + if ($pageloadClient === 'site' && !$app->isClient('site')) { + return; + } + + // Throttle: only run once per interval + $throttleSeconds = (int) $componentParams->get('pageload_interval', 300); + $lastRun = (int) $componentParams->get('_pageload_last_run', 0); + + if ((time() - $lastRun) < $throttleSeconds) { + return; + } + + if (!\Joomla\Component\MokoJoomCross\Administrator\Helper\QueueProcessor::hasPendingWork()) { + return; + } + + $this->updateLastRunTimestamp(); + + // Small batch to avoid slowing page loads + \Joomla\Component\MokoJoomCross\Administrator\Helper\QueueProcessor::processQueue(5); + } + + /** + * Store the last page-load run timestamp. + */ + private function updateLastRunTimestamp(): void + { + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select($db->quoteName('params')) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokojoomcross')); + + $db->setQuery($query); + $params = json_decode($db->loadResult() ?: '{}', true) ?: []; + $params['_pageload_last_run'] = time(); + + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params))) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokojoomcross')); + + $db->setQuery($query); + $db->execute(); + } + /** * Triggered after a content item is saved. */ diff --git a/src/packages/plg_task_mokojoomcross/index.html b/src/packages/plg_task_mokojoomcross/index.html new file mode 100644 index 0000000..6182993 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/index.html @@ -0,0 +1 @@ +<\!DOCTYPE html> diff --git a/src/packages/plg_task_mokojoomcross/language/en-GB/index.html b/src/packages/plg_task_mokojoomcross/language/en-GB/index.html new file mode 100644 index 0000000..6182993 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/language/en-GB/index.html @@ -0,0 +1 @@ +<\!DOCTYPE html> diff --git a/src/packages/plg_task_mokojoomcross/language/en-GB/plg_task_mokojoomcross.ini b/src/packages/plg_task_mokojoomcross/language/en-GB/plg_task_mokojoomcross.ini new file mode 100644 index 0000000..11a56c5 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/language/en-GB/plg_task_mokojoomcross.ini @@ -0,0 +1,9 @@ +; Task - MokoJoomCross Queue Processor Language File +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_TASK_MOKOJOOMCROSS="Task - MokoJoomCross Queue Processor" +PLG_TASK_MOKOJOOMCROSS_DESCRIPTION="Joomla Scheduled Task for processing the MokoJoomCross cross-post queue. Handles queued posts, retries, scheduled posts, and log cleanup." + +PLG_TASK_MOKOJOOMCROSS_PROCESS_QUEUE_TITLE="MokoJoomCross - Process Queue" +PLG_TASK_MOKOJOOMCROSS_PROCESS_QUEUE_DESC="Process queued cross-posts, retry failed posts, fire scheduled posts, and clean up old logs." diff --git a/src/packages/plg_task_mokojoomcross/language/en-GB/plg_task_mokojoomcross.sys.ini b/src/packages/plg_task_mokojoomcross/language/en-GB/plg_task_mokojoomcross.sys.ini new file mode 100644 index 0000000..734e841 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/language/en-GB/plg_task_mokojoomcross.sys.ini @@ -0,0 +1,2 @@ +PLG_TASK_MOKOJOOMCROSS="Task - MokoJoomCross Queue Processor" +PLG_TASK_MOKOJOOMCROSS_DESCRIPTION="Joomla Scheduled Task for processing the MokoJoomCross cross-post queue." diff --git a/src/packages/plg_task_mokojoomcross/language/index.html b/src/packages/plg_task_mokojoomcross/language/index.html new file mode 100644 index 0000000..6182993 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/language/index.html @@ -0,0 +1 @@ +<\!DOCTYPE html> diff --git a/src/packages/plg_task_mokojoomcross/mokojoomcross.php b/src/packages/plg_task_mokojoomcross/mokojoomcross.php new file mode 100644 index 0000000..ab8e4f7 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/mokojoomcross.php @@ -0,0 +1,12 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +defined('_JEXEC') or die; diff --git a/src/packages/plg_task_mokojoomcross/mokojoomcross.xml b/src/packages/plg_task_mokojoomcross/mokojoomcross.xml new file mode 100644 index 0000000..c5d5bd5 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/mokojoomcross.xml @@ -0,0 +1,26 @@ + + + Task - MokoJoomCross Queue Processor + 01.00.01-dev + 2026-05-28 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_TASK_MOKOJOOMCROSS_DESCRIPTION + + Joomla\Plugin\Task\MokoJoomCross + + + mokojoomcross.php + src + services + language + + + + language/en-GB/plg_task_mokojoomcross.ini + language/en-GB/plg_task_mokojoomcross.sys.ini + + diff --git a/src/packages/plg_task_mokojoomcross/services/index.html b/src/packages/plg_task_mokojoomcross/services/index.html new file mode 100644 index 0000000..6182993 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/services/index.html @@ -0,0 +1 @@ +<\!DOCTYPE html> diff --git a/src/packages/plg_task_mokojoomcross/services/provider.php b/src/packages/plg_task_mokojoomcross/services/provider.php new file mode 100644 index 0000000..8bf0746 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/services/provider.php @@ -0,0 +1,38 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use Joomla\Event\DispatcherInterface; +use Joomla\Plugin\Task\MokoJoomCross\Extension\MokoJoomCrossTask; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new MokoJoomCrossTask( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('task', 'mokojoomcross') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_task_mokojoomcross/src/Extension/MokoJoomCrossTask.php b/src/packages/plg_task_mokojoomcross/src/Extension/MokoJoomCrossTask.php new file mode 100644 index 0000000..0327658 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/src/Extension/MokoJoomCrossTask.php @@ -0,0 +1,85 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Plugin\Task\MokoJoomCross\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\MokoJoomCross\Administrator\Helper\QueueProcessor; +use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent; +use Joomla\Component\Scheduler\Administrator\Task\Status as TaskStatus; +use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait; +use Joomla\Event\SubscriberInterface; + +/** + * Joomla Scheduled Task plugin for MokoJoomCross queue processing. + * + * Registers with Joomla's Task Scheduler (System → Scheduled Tasks). + * Admin can create a task of type "MokoJoomCross - Process Queue" + * and configure the interval (recommended: every 5 minutes). + * + * This is the PREFERRED processing method. Page-load processing is + * a fallback for environments without cron/scheduler access. + */ +class MokoJoomCrossTask extends CMSPlugin implements SubscriberInterface +{ + use TaskPluginTrait; + + /** + * @var string[] The task type IDs this plugin provides + */ + protected const TASKS_MAP = [ + 'mokojoomcross.process_queue' => [ + 'langConstPrefix' => 'PLG_TASK_MOKOJOOMCROSS_PROCESS_QUEUE', + 'method' => 'processQueue', + 'form' => '', + ], + ]; + + public static function getSubscribedEvents(): array + { + return [ + 'onTaskOptionsList' => 'advertiseRoutines', + 'onExecuteTask' => 'standardRoutineHandler', + 'onContentPrepareForm' => 'enhanceTaskItemForm', + ]; + } + + /** + * Process the cross-post queue. + * + * @param ExecuteTaskEvent $event The task event + * + * @return int Task status code + */ + private function processQueue(ExecuteTaskEvent $event): int + { + $result = QueueProcessor::processQueue(20); + + // Log summary + $this->logTask(sprintf( + 'MokoJoomCross queue: %d processed, %d succeeded, %d failed, %d skipped', + $result['processed'], + $result['succeeded'], + $result['failed'], + $result['skipped'] + )); + + if ($result['skipped'] === -1) { + $this->logTask('Queue processing skipped — another process holds the lock'); + + return TaskStatus::KNOCKOUT; + } + + return TaskStatus::OK; + } +} diff --git a/src/packages/plg_task_mokojoomcross/src/Extension/index.html b/src/packages/plg_task_mokojoomcross/src/Extension/index.html new file mode 100644 index 0000000..6182993 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/src/Extension/index.html @@ -0,0 +1 @@ +<\!DOCTYPE html> diff --git a/src/packages/plg_task_mokojoomcross/src/index.html b/src/packages/plg_task_mokojoomcross/src/index.html new file mode 100644 index 0000000..6182993 --- /dev/null +++ b/src/packages/plg_task_mokojoomcross/src/index.html @@ -0,0 +1 @@ +<\!DOCTYPE html> diff --git a/src/pkg_mokojoomcross.xml b/src/pkg_mokojoomcross.xml index e29d2a0..996693c 100644 --- a/src/pkg_mokojoomcross.xml +++ b/src/pkg_mokojoomcross.xml @@ -19,6 +19,7 @@ plg_system_mokojoomcross.zip plg_content_mokojoomcross.zip plg_webservices_mokojoomcross.zip + plg_task_mokojoomcross.zip plg_mokojoomcross_facebook.zip diff --git a/src/script.php b/src/script.php index 6e3d090..cb7d5e9 100644 --- a/src/script.php +++ b/src/script.php @@ -63,6 +63,7 @@ class Pkg_MokoJoomCrossInstallerScript ['system', 'mokojoomcross'], ['content', 'mokojoomcross'], ['webservices', 'mokojoomcross'], + ['task', 'mokojoomcross'], ]; foreach ($corePlugins as [$folder, $element]) {