Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57f34f0fc7 | |||
| 58a9641b94 | |||
| 16c068b4b0 | |||
| de869c2b5d | |||
| b8cd0253e1 | |||
| c3899b65d3 | |||
| 7777ffca32 | |||
| 90340dd499 | |||
| 7747fef50e | |||
| 3586ce7661 | |||
| 83dc2fa013 | |||
| 9544f4f0bb |
@@ -9,6 +9,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Content plugin**: Fixed `onContentBeforeDisplay` signature for Joomla 5/6 — now accepts `BeforeDisplayEvent` object instead of individual parameters
|
||||
|
||||
### Added
|
||||
- **CrossPostDispatcher**: New static helper (`com_mokojoomcross/Helper/CrossPostDispatcher`) centralising dispatch logic for reuse by all source plugins
|
||||
- **Content plugin**: Added `onContentAfterSave` and `onContentChangeState` handlers with Joomla 5/6 event compatibility, dispatching via `CrossPostDispatcher`
|
||||
- **plg_system_mokojoomcross_events**: New source plugin for MokoJoomCalendar — cross-posts calendar events when published
|
||||
- **plg_system_mokojoomcross_gallery**: New source plugin for MokoJoomGallery — cross-posts galleries and images when published
|
||||
|
||||
### Fixed
|
||||
- **QueueProcessor**: Replaced read-then-write DB lock with MySQL advisory locks (`GET_LOCK`/`RELEASE_LOCK`) to eliminate race condition
|
||||
- **Twitter/X**: Replaced Bearer token auth with OAuth 1.0a (HMAC-SHA1) — Bearer tokens are app-only and cannot create tweets
|
||||
- **service.xml**: Fixed missing closing `</field>` tag on webhook method field
|
||||
- **Views**: Added missing `Toolbar` and `Route` imports in Logs, Posts, Services, Template, Templates HtmlView files
|
||||
@@ -50,6 +60,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- **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
|
||||
- **WordPress canonical URL**: WordPress cross-posts now include an "Originally published at" source link appended to content with the Joomla article URL
|
||||
- **REST API dispatch endpoint**: `POST /api/v1/mokojoomcross/dispatch` — trigger cross-posts for an article via API with optional service filtering, duplicate guard, and template rendering
|
||||
|
||||
### Added (original)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MokoJoomCross
|
||||
|
||||
<!-- VERSION: 01.00.18 -->
|
||||
<!-- VERSION: 01.00.22 -->
|
||||
|
||||
Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 5/6.
|
||||
|
||||
|
||||
@@ -465,6 +465,11 @@ 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."
|
||||
COM_MOKOJOOMCROSS_POSTS_N_SCHEDULED="%d post(s) scheduled."
|
||||
COM_MOKOJOOMCROSS_POSTS_NO_ITEM_SELECTED="No posts selected."
|
||||
COM_MOKOJOOMCROSS_SCHEDULE_NO_DATE="Please select a date and time for scheduling."
|
||||
COM_MOKOJOOMCROSS_TOOLBAR_SCHEDULE="Schedule"
|
||||
COM_MOKOJOOMCROSS_TOOLBAR_RETRY_SELECTED="Retry Selected"
|
||||
|
||||
; Queue Depth Warning
|
||||
COM_MOKOJOOMCROSS_DASHBOARD_QUEUE_DEPTH_WARNING_TITLE="Large queue backlog"
|
||||
@@ -489,3 +494,9 @@ COM_MOKOJOOMCROSS_PLACEHOLDER_HASHTAGS="Article tags as hashtags (#Tag1 #Tag2)"
|
||||
|
||||
; CSV Export
|
||||
COM_MOKOJOOMCROSS_EXPORT_CSV="Export CSV"
|
||||
|
||||
; API Dispatch
|
||||
COM_MOKOJOOMCROSS_DISPATCH_MISSING_ARTICLE="Missing or invalid article_id in request body."
|
||||
COM_MOKOJOOMCROSS_DISPATCH_INVALID_SERVICES="service_ids must be a non-empty array of service IDs."
|
||||
COM_MOKOJOOMCROSS_DISPATCH_ARTICLE_NOT_FOUND="Article not found."
|
||||
COM_MOKOJOOMCROSS_DISPATCH_NO_SERVICES="No enabled services found matching the request."
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomCross
|
||||
* @subpackage com_mokojoomcross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface;
|
||||
|
||||
/**
|
||||
* REST API controller for dispatching cross-posts.
|
||||
*
|
||||
* Endpoint: POST /api/index.php/v1/mokojoomcross/dispatch
|
||||
*
|
||||
* JSON body:
|
||||
* {
|
||||
* "article_id": 123,
|
||||
* "service_ids": [1, 2, 3] // optional — omit to post to all enabled services
|
||||
* }
|
||||
*
|
||||
* Returns JSON with the created post IDs and status.
|
||||
*/
|
||||
class DispatchController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Dispatch cross-posts for an article to one or more services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function dispatch(): void
|
||||
{
|
||||
$app = $this->app;
|
||||
|
||||
// Read JSON body
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$articleId = (int) ($input['article_id'] ?? 0);
|
||||
$serviceIds = $input['service_ids'] ?? null;
|
||||
|
||||
if ($articleId < 1) {
|
||||
$this->sendJsonResponse(['error' => Text::_('COM_MOKOJOOMCROSS_DISPATCH_MISSING_ARTICLE')], 400);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate service_ids if provided
|
||||
if ($serviceIds !== null) {
|
||||
if (!is_array($serviceIds) || empty($serviceIds)) {
|
||||
$this->sendJsonResponse(['error' => Text::_('COM_MOKOJOOMCROSS_DISPATCH_INVALID_SERVICES')], 400);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$serviceIds = array_map('intval', $serviceIds);
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load the article
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('id') . ' = ' . $articleId);
|
||||
|
||||
$db->setQuery($query);
|
||||
$article = $db->loadObject();
|
||||
|
||||
if (!$article) {
|
||||
$this->sendJsonResponse(['error' => Text::_('COM_MOKOJOOMCROSS_DISPATCH_ARTICLE_NOT_FOUND')], 404);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Load enabled services, optionally filtered by service_ids
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokojoomcross_services'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
|
||||
if ($serviceIds !== null) {
|
||||
$query->where($db->quoteName('id') . ' IN (' . implode(',', $serviceIds) . ')');
|
||||
}
|
||||
|
||||
$db->setQuery($query);
|
||||
$services = $db->loadObjectList() ?: [];
|
||||
|
||||
if (empty($services)) {
|
||||
$this->sendJsonResponse(['error' => Text::_('COM_MOKOJOOMCROSS_DISPATCH_NO_SERVICES')], 404);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Import service plugins and build type-to-plugin map
|
||||
PluginHelper::importPlugin('mokojoomcross');
|
||||
|
||||
$servicePlugins = [];
|
||||
|
||||
try {
|
||||
$app->getDispatcher()->dispatch(
|
||||
'onMokoJoomCrossGetServices',
|
||||
new \Joomla\Event\Event('onMokoJoomCrossGetServices', [&$servicePlugins])
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// Dispatcher may not be available
|
||||
}
|
||||
|
||||
$pluginMap = [];
|
||||
|
||||
foreach ($servicePlugins as $plugin) {
|
||||
if ($plugin instanceof MokoJoomCrossServiceInterface) {
|
||||
$pluginMap[$plugin->getServiceType()] = $plugin;
|
||||
}
|
||||
}
|
||||
|
||||
// Render template and create queue entries (same logic as system plugin dispatchCrossPost)
|
||||
$componentParams = ComponentHelper::getParams('com_mokojoomcross');
|
||||
$now = Factory::getDate()->toSql();
|
||||
$createdIds = [];
|
||||
$skipped = [];
|
||||
|
||||
// Build article URL
|
||||
$articleUrl = Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id;
|
||||
|
||||
if (!empty($article->catid)) {
|
||||
$articleUrl .= '&catid=' . $article->catid;
|
||||
}
|
||||
|
||||
// Extract intro image for media
|
||||
$media = [];
|
||||
$images = json_decode($article->images ?? '{}');
|
||||
|
||||
if (!empty($images->image_intro)) {
|
||||
$media[] = Uri::root() . ltrim($images->image_intro, '/');
|
||||
}
|
||||
|
||||
foreach ($services as $service) {
|
||||
// Duplicate guard — skip if article already posted/queued for this service
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokojoomcross_posts'))
|
||||
->where($db->quoteName('article_id') . ' = ' . (int) $article->id)
|
||||
->where($db->quoteName('service_id') . ' = ' . (int) $service->id)
|
||||
->where($db->quoteName('status') . ' IN (' . $db->quote('queued') . ',' . $db->quote('posted') . ',' . $db->quote('posting') . ')');
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
if ((int) $db->loadResult() > 0) {
|
||||
$skipped[] = [
|
||||
'service_id' => (int) $service->id,
|
||||
'service_type' => $service->service_type,
|
||||
'reason' => 'duplicate',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Render template
|
||||
$message = $this->renderTemplate($db, $article, $service, $componentParams);
|
||||
|
||||
// Create queue entry
|
||||
$post = (object) [
|
||||
'article_id' => (int) $article->id,
|
||||
'service_id' => (int) $service->id,
|
||||
'status' => 'queued',
|
||||
'message' => $message,
|
||||
'platform_post_id' => '',
|
||||
'platform_response' => '',
|
||||
'error_message' => '',
|
||||
'retry_count' => 0,
|
||||
'created' => $now,
|
||||
'modified' => $now,
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokojoomcross_posts', $post);
|
||||
$postId = (int) $db->insertid();
|
||||
|
||||
$createdIds[] = [
|
||||
'post_id' => $postId,
|
||||
'service_id' => (int) $service->id,
|
||||
'service_type' => $service->service_type,
|
||||
'status' => 'queued',
|
||||
];
|
||||
|
||||
// Write log entry
|
||||
$log = (object) [
|
||||
'post_id' => $postId,
|
||||
'service_id' => (int) $service->id,
|
||||
'level' => 'info',
|
||||
'message' => sprintf('API dispatch: queued article %d to %s', $article->id, $service->service_type),
|
||||
'context' => '{}',
|
||||
'created' => $now,
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokojoomcross_logs', $log);
|
||||
}
|
||||
|
||||
$this->sendJsonResponse([
|
||||
'article_id' => (int) $article->id,
|
||||
'dispatched' => $createdIds,
|
||||
'skipped' => $skipped,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the message template for a service (simplified version of system plugin logic).
|
||||
*/
|
||||
private function renderTemplate($db, object $article, object $service, $componentParams): string
|
||||
{
|
||||
// Try service-specific template first, fall back to default
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('template_body'))
|
||||
->from($db->quoteName('#__mokojoomcross_templates'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where('(' . $db->quoteName('service_type') . ' = ' . $db->quote($service->service_type)
|
||||
. ' OR ' . $db->quoteName('service_type') . ' = ' . $db->quote('default') . ')')
|
||||
->order('CASE WHEN ' . $db->quoteName('service_type') . ' = '
|
||||
. $db->quote($service->service_type) . ' THEN 0 ELSE 1 END')
|
||||
->setLimit(1);
|
||||
|
||||
$db->setQuery($query);
|
||||
$template = $db->loadResult() ?: ($componentParams->get('default_template', "{title}\n\n{url}"));
|
||||
|
||||
// Build article URL
|
||||
$url = Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id;
|
||||
|
||||
if (!empty($article->catid)) {
|
||||
$url .= '&catid=' . $article->catid;
|
||||
}
|
||||
|
||||
// Resolve category name
|
||||
$categoryName = '';
|
||||
|
||||
if (!empty($article->catid)) {
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('title'))
|
||||
->from($db->quoteName('#__categories'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $article->catid);
|
||||
$db->setQuery($query);
|
||||
$categoryName = $db->loadResult() ?: '';
|
||||
}
|
||||
|
||||
// Resolve author name
|
||||
$authorName = '';
|
||||
|
||||
if (!empty($article->created_by)) {
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('name'))
|
||||
->from($db->quoteName('#__users'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $article->created_by);
|
||||
$db->setQuery($query);
|
||||
$authorName = $db->loadResult() ?: '';
|
||||
}
|
||||
|
||||
// Extract intro image
|
||||
$introImage = '';
|
||||
$images = json_decode($article->images ?? '{}');
|
||||
|
||||
if (!empty($images->image_intro)) {
|
||||
$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));
|
||||
|
||||
$replacements = [
|
||||
'{title}' => $article->title ?? '',
|
||||
'{introtext}' => strip_tags(mb_substr($article->introtext ?? '', 0, 280)),
|
||||
'{fulltext}' => strip_tags(mb_substr($article->fulltext ?? '', 0, 500)),
|
||||
'{url}' => $url,
|
||||
'{image}' => $introImage,
|
||||
'{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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON response and close the application.
|
||||
*
|
||||
* @param array $data Response data
|
||||
* @param int $httpCode HTTP status code
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function sendJsonResponse(array $data, int $httpCode): void
|
||||
{
|
||||
$app = $this->app;
|
||||
|
||||
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
$app->setHeader('Status', (string) $httpCode);
|
||||
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$app->close();
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,87 @@ class PostsController extends AdminController
|
||||
return parent::getModel($name, $prefix, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule selected posts for a future date/time.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function schedule(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
$ids = $this->input->get('cid', [], 'array');
|
||||
$scheduledAt = $this->input->getString('scheduled_at', '');
|
||||
|
||||
if (empty($ids)) {
|
||||
$this->setRedirect(
|
||||
Route::_('index.php?option=com_mokojoomcross&view=posts', false),
|
||||
Text::_('COM_MOKOJOOMCROSS_POSTS_NO_ITEM_SELECTED'),
|
||||
'warning'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($scheduledAt)) {
|
||||
$this->setRedirect(
|
||||
Route::_('index.php?option=com_mokojoomcross&view=posts', false),
|
||||
Text::_('COM_MOKOJOOMCROSS_SCHEDULE_NO_DATE'),
|
||||
'warning'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__mokojoomcross_posts'))
|
||||
->set($db->quoteName('scheduled_at') . ' = ' . $db->quote($scheduledAt))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote('queued'))
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote($now))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $id);
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
$this->setRedirect(
|
||||
Route::_('index.php?option=com_mokojoomcross&view=posts', false),
|
||||
Text::sprintf('COM_MOKOJOOMCROSS_POSTS_N_SCHEDULED', count($ids)),
|
||||
'success'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry selected failed/permanently_failed posts.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function retrySelected(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
$ids = $this->input->get('cid', [], 'array');
|
||||
|
||||
if (empty($ids)) {
|
||||
$this->setRedirect(
|
||||
Route::_('index.php?option=com_mokojoomcross&view=posts', false),
|
||||
Text::_('COM_MOKOJOOMCROSS_POSTS_NO_ITEM_SELECTED'),
|
||||
'warning'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$count = \Joomla\Component\MokoJoomCross\Administrator\Helper\QueueProcessor::retryPosts($ids);
|
||||
|
||||
$this->setRedirect(
|
||||
Route::_('index.php?option=com_mokojoomcross&view=posts', false),
|
||||
Text::sprintf('COM_MOKOJOOMCROSS_POSTS_N_RETRIED', $count),
|
||||
'success'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-queue all failed posts by resetting their status to queued and retry count to 0.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomCross
|
||||
* @subpackage com_mokojoomcross
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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\CMS\Uri\Uri;
|
||||
use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface;
|
||||
|
||||
/**
|
||||
* Static dispatcher for cross-posting content from any source plugin.
|
||||
*
|
||||
* Centralises the dispatch logic that was previously only in the system plugin,
|
||||
* so content-type source plugins (articles, calendar events, gallery items) can
|
||||
* trigger cross-posts without coupling to plg_system_mokojoomcross.
|
||||
*/
|
||||
class CrossPostDispatcher
|
||||
{
|
||||
/**
|
||||
* Dispatch an article-like payload to all enabled cross-post services.
|
||||
*
|
||||
* @param object $article Article or article-like object
|
||||
* @param string $articleUrl Canonical URL for the content item
|
||||
* @param string|null $contentType Content type context (e.g. 'com_content.article')
|
||||
*/
|
||||
public static function dispatch(object $article, string $articleUrl = '', ?string $contentType = null): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load all enabled services
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokojoomcross_services'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
$services = $db->loadObjectList();
|
||||
|
||||
if (empty($services)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Import service plugins so they register with the dispatcher
|
||||
PluginHelper::importPlugin('mokojoomcross');
|
||||
|
||||
// Collect registered service plugin instances
|
||||
$servicePlugins = [];
|
||||
Factory::getApplication()->getDispatcher()->dispatch(
|
||||
'onMokoJoomCrossGetServices',
|
||||
new \Joomla\Event\Event('onMokoJoomCrossGetServices', [&$servicePlugins])
|
||||
);
|
||||
|
||||
// Index by service type for lookup
|
||||
$pluginMap = [];
|
||||
|
||||
foreach ($servicePlugins as $plugin) {
|
||||
if ($plugin instanceof MokoJoomCrossServiceInterface) {
|
||||
$pluginMap[$plugin->getServiceType()] = $plugin;
|
||||
}
|
||||
}
|
||||
|
||||
$componentParams = ComponentHelper::getParams('com_mokojoomcross');
|
||||
|
||||
// Per-article selective cross-posting (#19)
|
||||
$attribs = json_decode($article->attribs ?? '{}', true) ?: [];
|
||||
$selectedServiceIds = $attribs['mokojoomcross_services'] ?? null;
|
||||
$skipCrossPost = !empty($attribs['mokojoomcross_skip']);
|
||||
|
||||
if ($skipCrossPost) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If specific services selected, convert to array of ints for filtering
|
||||
if (is_array($selectedServiceIds) && !empty($selectedServiceIds)) {
|
||||
$selectedServiceIds = array_map('intval', $selectedServiceIds);
|
||||
} else {
|
||||
$selectedServiceIds = null; // null = post to all
|
||||
}
|
||||
|
||||
// Determine service type filter from content type property
|
||||
$serviceTypeFilter = $article->_content_type ?? null;
|
||||
|
||||
foreach ($services as $service) {
|
||||
// Service type filter for non-article content types
|
||||
if ($serviceTypeFilter !== null && $service->service_type !== $serviceTypeFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Per-article filter
|
||||
if ($selectedServiceIds !== null && !in_array((int) $service->id, $selectedServiceIds, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Duplicate guard
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokojoomcross_posts'))
|
||||
->where($db->quoteName('article_id') . ' = ' . (int) $article->id)
|
||||
->where($db->quoteName('service_id') . ' = ' . (int) $service->id)
|
||||
->where($db->quoteName('status') . ' IN (' . $db->quote('queued') . ',' . $db->quote('posted') . ',' . $db->quote('posting') . ')');
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
if ((int) $db->loadResult() > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$message = self::renderTemplate($article, $service);
|
||||
|
||||
// Extract intro image for media attachment
|
||||
$media = [];
|
||||
$images = json_decode($article->images ?? '{}');
|
||||
|
||||
if (!empty($images->image_intro)) {
|
||||
$media[] = Uri::root() . ltrim($images->image_intro, '/');
|
||||
}
|
||||
|
||||
// Create queue entry
|
||||
$post = (object) [
|
||||
'article_id' => (int) $article->id,
|
||||
'service_id' => (int) $service->id,
|
||||
'status' => 'queued',
|
||||
'message' => $message,
|
||||
'platform_post_id' => '',
|
||||
'platform_response' => '',
|
||||
'error_message' => '',
|
||||
'retry_count' => 0,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
'modified' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokojoomcross_posts', $post);
|
||||
$postId = $db->insertid();
|
||||
|
||||
// Resolve article URL
|
||||
$url = $article->_article_url ?? $articleUrl;
|
||||
|
||||
if (empty($url)) {
|
||||
$url = Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id
|
||||
. (!empty($article->catid) ? '&catid=' . $article->catid : '');
|
||||
}
|
||||
|
||||
// Attempt immediate dispatch if service plugin is available
|
||||
$plugin = $pluginMap[$service->service_type] ?? null;
|
||||
|
||||
if ($plugin) {
|
||||
self::executePost($db, $postId, $plugin, $message, $service, $media, $url);
|
||||
} else {
|
||||
self::log($db, $postId, $service->id, 'warning',
|
||||
sprintf('No service plugin found for type "%s" — post remains queued', $service->service_type));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a cross-post via the service plugin.
|
||||
*/
|
||||
private static function executePost($db, int $postId, MokoJoomCrossServiceInterface $plugin, string $message, object $service, array $media = [], string $articleUrl = ''): void
|
||||
{
|
||||
// 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') . ' = ' . $postId)
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
$credentials = json_decode($service->credentials ?: '{}', true) ?: [];
|
||||
$params = json_decode($service->params ?: '{}', true) ?: [];
|
||||
|
||||
if (!empty($articleUrl)) {
|
||||
$params['_article_url'] = $articleUrl;
|
||||
}
|
||||
|
||||
// Lifecycle event: before post
|
||||
$cancel = false;
|
||||
$dispatcher = Factory::getApplication()->getDispatcher();
|
||||
|
||||
try {
|
||||
$beforeEvent = new \Joomla\Event\Event('onMokoJoomCrossBeforePost', [$postId, &$message, $service->service_type, &$cancel]);
|
||||
$dispatcher->dispatch('onMokoJoomCrossBeforePost', $beforeEvent);
|
||||
} catch (\Throwable $e) {
|
||||
// Dispatcher may not be available
|
||||
}
|
||||
|
||||
if ($cancel) {
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokojoomcross_posts'))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote('cancelled'))
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql()))
|
||||
->where($db->quoteName('id') . ' = ' . $postId)
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
self::log($db, $postId, $service->id, 'info',
|
||||
sprintf('Post to %s cancelled by onMokoJoomCrossBeforePost event', $service->service_type));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $plugin->publish($message, $media, $credentials, $params);
|
||||
|
||||
if (!empty($result['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($result['platform_post_id'] ?? ''))
|
||||
->set($db->quoteName('platform_response') . ' = ' . $db->quote(json_encode($result['response'] ?? [])))
|
||||
->set($db->quoteName('posted_at') . ' = ' . $db->quote(Factory::getDate()->toSql()))
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql()))
|
||||
->where($db->quoteName('id') . ' = ' . $postId)
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
self::log($db, $postId, $service->id, 'info',
|
||||
sprintf('Posted to %s (platform ID: %s)', $service->service_type, $result['platform_post_id'] ?? 'n/a'));
|
||||
|
||||
try {
|
||||
$afterEvent = new \Joomla\Event\Event('onMokoJoomCrossAfterPost', [$postId, $service->service_type, $result]);
|
||||
$dispatcher->dispatch('onMokoJoomCrossAfterPost', $afterEvent);
|
||||
} catch (\Throwable $e) {
|
||||
// Non-critical
|
||||
}
|
||||
} else {
|
||||
$errorMsg = $result['response']['error'] ?? json_encode($result['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($result['response'] ?? [])))
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql()))
|
||||
->where($db->quoteName('id') . ' = ' . $postId)
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
self::log($db, $postId, $service->id, 'error',
|
||||
sprintf('Failed to post to %s: %s', $service->service_type, $errorMsg));
|
||||
|
||||
try {
|
||||
$failedEvent = new \Joomla\Event\Event('onMokoJoomCrossPostFailed', [$postId, $service->service_type, $errorMsg]);
|
||||
$dispatcher->dispatch('onMokoJoomCrossPostFailed', $failedEvent);
|
||||
} catch (\Throwable $e) {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
} 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') . ' = ' . $postId)
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
self::log($db, $postId, $service->id, 'error',
|
||||
sprintf('Exception posting to %s: %s', $service->service_type, $e->getMessage()));
|
||||
|
||||
try {
|
||||
$failedEvent = new \Joomla\Event\Event('onMokoJoomCrossPostFailed', [$postId, $service->service_type, $e->getMessage()]);
|
||||
$dispatcher->dispatch('onMokoJoomCrossPostFailed', $failedEvent);
|
||||
} catch (\Throwable $ex) {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the message template for a service.
|
||||
*/
|
||||
private static function renderTemplate(object $article, object $service): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Try service-specific template first, fall back to default
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('template_body'))
|
||||
->from($db->quoteName('#__mokojoomcross_templates'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where('(' . $db->quoteName('service_type') . ' = ' . $db->quote($service->service_type)
|
||||
. ' OR ' . $db->quoteName('service_type') . ' = ' . $db->quote('default') . ')')
|
||||
->order('CASE WHEN ' . $db->quoteName('service_type') . ' = '
|
||||
. $db->quote($service->service_type) . ' THEN 0 ELSE 1 END')
|
||||
->setLimit(1);
|
||||
|
||||
$db->setQuery($query);
|
||||
$template = $db->loadResult() ?: "{title}\n\n{url}";
|
||||
|
||||
// Build SEF article URL
|
||||
$url = $article->_article_url
|
||||
?? (Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id
|
||||
. (!empty($article->catid) ? '&catid=' . $article->catid : ''));
|
||||
|
||||
// Resolve category name
|
||||
$categoryName = '';
|
||||
|
||||
if (!empty($article->catid)) {
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('title'))
|
||||
->from($db->quoteName('#__categories'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $article->catid);
|
||||
$db->setQuery($query);
|
||||
$categoryName = $db->loadResult() ?: '';
|
||||
}
|
||||
|
||||
// Resolve author name
|
||||
$authorName = '';
|
||||
|
||||
if (!empty($article->created_by)) {
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('name'))
|
||||
->from($db->quoteName('#__users'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $article->created_by);
|
||||
$db->setQuery($query);
|
||||
$authorName = $db->loadResult() ?: '';
|
||||
}
|
||||
|
||||
// Extract intro image
|
||||
$introImage = '';
|
||||
$images = json_decode($article->images ?? '{}');
|
||||
|
||||
if (!empty($images->image_intro)) {
|
||||
$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 ?? '',
|
||||
'{introtext}' => strip_tags(mb_substr($article->introtext ?? '', 0, 280)),
|
||||
'{fulltext}' => strip_tags(mb_substr($article->fulltext ?? '', 0, 500)),
|
||||
'{url}' => $url,
|
||||
'{image}' => $introImage,
|
||||
'{category}' => $categoryName,
|
||||
'{author}' => $authorName,
|
||||
'{date}' => Factory::getDate($article->publish_up ?? 'now')->format('Y-m-d'),
|
||||
'{tags}' => $tagsComma,
|
||||
'{hashtags}' => $hashtags,
|
||||
];
|
||||
|
||||
$message = str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||
|
||||
// Resolve custom field placeholders: {field:field_name}
|
||||
$message = preg_replace_callback('/\{field:([a-zA-Z0-9_-]+)\}/', function ($matches) use ($db, $article) {
|
||||
$fieldName = $matches[1];
|
||||
$query = $db->getQuery(true)
|
||||
->select('fv.value')
|
||||
->from($db->quoteName('#__fields_values', 'fv'))
|
||||
->join('INNER', $db->quoteName('#__fields', 'f') . ' ON f.id = fv.field_id')
|
||||
->where('f.name = ' . $db->quote($fieldName))
|
||||
->where('fv.item_id = ' . (int) $article->id);
|
||||
$db->setQuery($query);
|
||||
return $db->loadResult() ?: '';
|
||||
}, $message);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an entry to the activity log.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,107 @@ class OAuthHelper
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an OAuth token if it has expired.
|
||||
*
|
||||
* Checks `token_expires` in the credentials array. If the token is expired
|
||||
* and a refresh_token is available, performs the refresh grant and updates
|
||||
* both the DB and the passed-in credentials array.
|
||||
*
|
||||
* @param int $serviceId Service record ID
|
||||
* @param array &$credentials Credentials array (updated by reference on refresh)
|
||||
*
|
||||
* @return bool True if token was refreshed, false otherwise
|
||||
*/
|
||||
public static function refreshTokenIfNeeded(int $serviceId, array &$credentials): bool
|
||||
{
|
||||
// No expiry set — nothing to refresh
|
||||
if (empty($credentials['token_expires'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Token not yet expired
|
||||
if ((int) $credentials['token_expires'] >= time()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Expired but no refresh token available
|
||||
if (empty($credentials['refresh_token'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Look up the service type from DB
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('service_type'))
|
||||
->from($db->quoteName('#__mokojoomcross_services'))
|
||||
->where($db->quoteName('id') . ' = ' . $serviceId);
|
||||
$db->setQuery($query);
|
||||
$serviceType = $db->loadResult();
|
||||
|
||||
if (!$serviceType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get OAuth config for this service type
|
||||
$config = self::OAUTH_CONFIGS[$serviceType] ?? null;
|
||||
|
||||
if (!$config || empty($config['token_url'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// POST refresh token grant
|
||||
$postData = [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $credentials['refresh_token'],
|
||||
];
|
||||
|
||||
// Include client credentials if available
|
||||
if (!empty($credentials['client_id'])) {
|
||||
$postData['client_id'] = $credentials['client_id'];
|
||||
}
|
||||
|
||||
if (!empty($credentials['client_secret'])) {
|
||||
$postData['client_secret'] = $credentials['client_secret'];
|
||||
}
|
||||
|
||||
$ch = curl_init($config['token_url']);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($postData),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', 'Accept: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$data = json_decode($response, true) ?: [];
|
||||
|
||||
if ($httpCode >= 200 && $httpCode < 300 && !empty($data['access_token'])) {
|
||||
// Store updated token in DB
|
||||
self::storeToken($serviceId, $data);
|
||||
|
||||
// Update credentials by reference
|
||||
$credentials['access_token'] = $data['access_token'];
|
||||
|
||||
if (!empty($data['refresh_token'])) {
|
||||
$credentials['refresh_token'] = $data['refresh_token'];
|
||||
}
|
||||
|
||||
if (!empty($data['expires_in'])) {
|
||||
$credentials['token_expires'] = time() + (int) $data['expires_in'];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OAuth callback URL for this Joomla installation.
|
||||
*
|
||||
|
||||
@@ -16,6 +16,7 @@ defined('_JEXEC') or die;
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface;
|
||||
|
||||
/**
|
||||
@@ -104,9 +105,28 @@ class QueueProcessor
|
||||
$isRetry = ($post->status === 'failed');
|
||||
|
||||
if ($isRetry) {
|
||||
// Increment retry count
|
||||
$newRetryCount = (int) $post->retry_count + 1;
|
||||
|
||||
// If this is the last retry attempt, mark permanently failed on failure
|
||||
if ($newRetryCount >= $maxRetry) {
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokojoomcross_posts'))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote('permanently_failed'))
|
||||
->set($db->quoteName('retry_count') . ' = ' . $newRetryCount)
|
||||
->set($db->quoteName('error_message') . ' = CONCAT(' . $db->quoteName('error_message') . ', ' . $db->quote(' [max retries exceeded]') . ')')
|
||||
->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('Permanently failed %s: max retries (%d) exceeded', $post->service_type, $maxRetry));
|
||||
|
||||
$result['failed']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokojoomcross_posts'))
|
||||
@@ -129,8 +149,60 @@ class QueueProcessor
|
||||
$credentials = json_decode($post->credentials ?: '{}', true) ?: [];
|
||||
$params = json_decode($post->service_params ?: '{}', true) ?: [];
|
||||
|
||||
// Token auto-refresh before posting
|
||||
OAuthHelper::refreshTokenIfNeeded((int) $post->service_id, $credentials);
|
||||
|
||||
// Extract intro image for media attachment
|
||||
$media = [];
|
||||
|
||||
if (!empty($post->article_id)) {
|
||||
$imgQuery = $db->getQuery(true)
|
||||
->select($db->quoteName('images'))
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $post->article_id);
|
||||
$db->setQuery($imgQuery);
|
||||
$imgJson = $db->loadResult();
|
||||
|
||||
if ($imgJson) {
|
||||
$imgData = json_decode($imgJson);
|
||||
|
||||
if (!empty($imgData->image_intro)) {
|
||||
$media[] = Uri::root() . ltrim($imgData->image_intro, '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle event: before post
|
||||
$cancel = false;
|
||||
$message = $post->message;
|
||||
|
||||
try {
|
||||
$apiResult = $plugin->publish($post->message, [], $credentials, $params);
|
||||
$dispatcher = Factory::getApplication()->getDispatcher();
|
||||
$beforeEvent = new \Joomla\Event\Event('onMokoJoomCrossBeforePost', [(int) $post->id, &$message, $post->service_type, &$cancel]);
|
||||
$dispatcher->dispatch('onMokoJoomCrossBeforePost', $beforeEvent);
|
||||
} catch (\Throwable $e) {
|
||||
// Dispatcher may not be available
|
||||
}
|
||||
|
||||
if ($cancel) {
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->update($db->quoteName('#__mokojoomcross_posts'))
|
||||
->set($db->quoteName('status') . ' = ' . $db->quote('cancelled'))
|
||||
->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('Post to %s cancelled by onMokoJoomCrossBeforePost event', $post->service_type));
|
||||
|
||||
$result['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$apiResult = $plugin->publish($message, $media, $credentials, $params);
|
||||
|
||||
if (!empty($apiResult['success'])) {
|
||||
$db->setQuery(
|
||||
@@ -148,6 +220,14 @@ class QueueProcessor
|
||||
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'));
|
||||
|
||||
// Lifecycle event: after successful post
|
||||
try {
|
||||
$afterEvent = new \Joomla\Event\Event('onMokoJoomCrossAfterPost', [(int) $post->id, $post->service_type, $apiResult]);
|
||||
$dispatcher->dispatch('onMokoJoomCrossAfterPost', $afterEvent);
|
||||
} catch (\Throwable $e) {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
$result['succeeded']++;
|
||||
} else {
|
||||
$errorMsg = $apiResult['response']['error'] ?? json_encode($apiResult['response'] ?? []);
|
||||
@@ -166,6 +246,14 @@ class QueueProcessor
|
||||
self::log($db, (int) $post->id, (int) $post->service_id, 'error',
|
||||
sprintf('Failed %s: %s', $post->service_type, mb_substr($errorMsg, 0, 500)));
|
||||
|
||||
// Lifecycle event: post failed
|
||||
try {
|
||||
$failedEvent = new \Joomla\Event\Event('onMokoJoomCrossPostFailed', [(int) $post->id, $post->service_type, $errorMsg]);
|
||||
$dispatcher->dispatch('onMokoJoomCrossPostFailed', $failedEvent);
|
||||
} catch (\Throwable $e) {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
$result['failed']++;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
@@ -182,6 +270,14 @@ class QueueProcessor
|
||||
self::log($db, (int) $post->id, (int) $post->service_id, 'error',
|
||||
sprintf('Exception %s: %s', $post->service_type, mb_substr($e->getMessage(), 0, 500)));
|
||||
|
||||
// Lifecycle event: post failed (exception)
|
||||
try {
|
||||
$failedEvent = new \Joomla\Event\Event('onMokoJoomCrossPostFailed', [(int) $post->id, $post->service_type, $e->getMessage()]);
|
||||
$dispatcher->dispatch('onMokoJoomCrossPostFailed', $failedEvent);
|
||||
} catch (\Throwable $ex) {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
$result['failed']++;
|
||||
}
|
||||
}
|
||||
@@ -454,7 +550,94 @@ class QueueProcessor
|
||||
'{hashtags}' => $hashtags,
|
||||
];
|
||||
|
||||
return str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||
$message = str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||
|
||||
// Resolve custom field placeholders: {field:field_name}
|
||||
$message = preg_replace_callback('/\{field:([a-zA-Z0-9_-]+)\}/', function ($matches) use ($db, $article) {
|
||||
$fieldName = $matches[1];
|
||||
$query = $db->getQuery(true)
|
||||
->select('fv.value')
|
||||
->from($db->quoteName('#__fields_values', 'fv'))
|
||||
->join('INNER', $db->quoteName('#__fields', 'f') . ' ON f.id = fv.field_id')
|
||||
->where('f.name = ' . $db->quote($fieldName))
|
||||
->where('fv.item_id = ' . (int) $article->id);
|
||||
$db->setQuery($query);
|
||||
return $db->loadResult() ?: '';
|
||||
}, $message);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually retry one or more failed/permanently_failed posts.
|
||||
*
|
||||
* Resets status to 'queued' and retry_count to 0 so the queue processor
|
||||
* picks them up on the next run.
|
||||
*
|
||||
* @param array $postIds Post IDs to retry
|
||||
*
|
||||
* @return int Number of posts re-queued
|
||||
*/
|
||||
public static function retryPosts(array $postIds): int
|
||||
{
|
||||
if (empty($postIds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$now = Factory::getDate()->toSql();
|
||||
$ids = implode(',', array_map('intval', $postIds));
|
||||
|
||||
$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($now))
|
||||
->where($db->quoteName('id') . ' IN (' . $ids . ')')
|
||||
->where($db->quoteName('status') . ' IN (' . $db->quote('failed') . ',' . $db->quote('permanently_failed') . ')');
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
$count = $db->getAffectedRows();
|
||||
|
||||
if ($count > 0) {
|
||||
self::log($db, null, null, 'info', sprintf('Manual retry: %d post(s) re-queued', $count));
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry all failed posts for a specific service.
|
||||
*
|
||||
* @param int $serviceId Service ID
|
||||
*
|
||||
* @return int Number of posts re-queued
|
||||
*/
|
||||
public static function retryService(int $serviceId): int
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
$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($now))
|
||||
->where($db->quoteName('service_id') . ' = ' . $serviceId)
|
||||
->where($db->quoteName('status') . ' IN (' . $db->quote('failed') . ',' . $db->quote('permanently_failed') . ')');
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
$count = $db->getAffectedRows();
|
||||
|
||||
if ($count > 0) {
|
||||
self::log($db, null, $serviceId, 'info', sprintf('Bulk retry: %d post(s) re-queued for service %d', $count, $serviceId));
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -548,68 +731,27 @@ class QueueProcessor
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple DB-based lock to prevent concurrent queue processing.
|
||||
* Acquire a MySQL advisory lock to prevent concurrent queue processing.
|
||||
*
|
||||
* Uses GET_LOCK() which is atomic — no race condition possible.
|
||||
* The 0 timeout means non-blocking (returns immediately if lock is held).
|
||||
* MySQL automatically releases the lock if the connection drops.
|
||||
*/
|
||||
private static function acquireLock(): bool
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db->setQuery("SELECT GET_LOCK('mokojoomcross_queue', 0)");
|
||||
|
||||
// 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;
|
||||
return (int) $db->loadResult() === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the processing lock.
|
||||
* Release the MySQL advisory 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->setQuery("SELECT RELEASE_LOCK('mokojoomcross_queue')");
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
|
||||
@@ -70,4 +70,15 @@ interface MokoJoomCrossServiceInterface
|
||||
* @return bool
|
||||
*/
|
||||
public function supportsMedia(): bool;
|
||||
|
||||
/**
|
||||
* Get the media types this service supports.
|
||||
*
|
||||
* Return an array of supported types: 'image', 'video', 'gif', 'document'.
|
||||
* Services that return an empty array are text-only.
|
||||
* Default implementation returns ['image'] if supportsMedia() is true.
|
||||
*
|
||||
* @return string[] e.g. ['image', 'video', 'gif']
|
||||
*/
|
||||
public function getSupportedMediaTypes(): array;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,13 @@ class HtmlView extends BaseHtmlView
|
||||
->icon('icon-trash')
|
||||
->listCheck(false);
|
||||
|
||||
$toolbar->standardButton('retry-selected', 'COM_MOKOJOOMCROSS_TOOLBAR_RETRY_SELECTED', 'posts.retrySelected')
|
||||
->icon('icon-redo')
|
||||
->listCheck(true);
|
||||
$toolbar->standardButton('schedule', 'COM_MOKOJOOMCROSS_TOOLBAR_SCHEDULE', 'posts.schedule')
|
||||
->icon('icon-calendar')
|
||||
->listCheck(true);
|
||||
|
||||
ToolbarHelper::deleteList('', 'posts.delete', 'JTOOLBAR_DELETE');
|
||||
|
||||
// Export CSV button
|
||||
|
||||
@@ -13,10 +13,13 @@ namespace Joomla\Plugin\Content\MokoJoomCross\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Event\Model\PrepareFormEvent;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Form\Form;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Component\MokoJoomCross\Administrator\Helper\CrossPostDispatcher;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
/**
|
||||
@@ -31,6 +34,8 @@ class MokoJoomCrossContent extends CMSPlugin implements SubscriberInterface
|
||||
return [
|
||||
'onContentBeforeDisplay' => 'onContentBeforeDisplay',
|
||||
'onContentPrepareForm' => 'onContentPrepareForm',
|
||||
'onContentAfterSave' => 'onContentAfterSave',
|
||||
'onContentChangeState' => 'onContentChangeState',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -143,9 +148,22 @@ XML;
|
||||
|
||||
/**
|
||||
* Add cross-post status badges before article content in admin.
|
||||
*
|
||||
* Joomla 5/6 compatible — accepts both BeforeDisplayEvent and legacy parameters.
|
||||
*/
|
||||
public function onContentBeforeDisplay(string $context, &$article, &$params, int $page = 0): string
|
||||
public function onContentBeforeDisplay($event): string
|
||||
{
|
||||
// Joomla 5/6 compatibility
|
||||
if ($event instanceof \Joomla\CMS\Event\Content\BeforeDisplayEvent) {
|
||||
$context = $event->getContext();
|
||||
$article = $event->getItem();
|
||||
} elseif (is_string($event)) {
|
||||
$context = $event;
|
||||
$article = func_get_arg(1);
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($context !== 'com_content.article') {
|
||||
return '';
|
||||
}
|
||||
@@ -187,4 +205,100 @@ XML;
|
||||
|
||||
return '<div class="mokojoomcross-status mb-2">' . $badges . '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch cross-post when an article is saved and published.
|
||||
*
|
||||
* Joomla 5/6 compatible — accepts both AfterSaveEvent and legacy parameters.
|
||||
*/
|
||||
public function onContentAfterSave($event): void
|
||||
{
|
||||
// Joomla 5/6 compatibility
|
||||
if ($event instanceof \Joomla\CMS\Event\Content\AfterSaveEvent) {
|
||||
$context = $event->getContext();
|
||||
$article = $event->getItem();
|
||||
$isNew = $event->getIsNew();
|
||||
} else {
|
||||
$context = $event;
|
||||
$article = func_get_arg(1);
|
||||
$isNew = func_get_arg(2);
|
||||
}
|
||||
|
||||
if ($context !== 'com_content.article') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) ($article->state ?? 0) !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$params = ComponentHelper::getParams('com_mokojoomcross');
|
||||
|
||||
if (!$params->get('auto_post_on_publish', 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($params->get('post_on_first_publish_only', 0) && !$isNew) {
|
||||
return;
|
||||
}
|
||||
|
||||
$url = Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id;
|
||||
|
||||
if (!empty($article->catid)) {
|
||||
$url .= '&catid=' . $article->catid;
|
||||
}
|
||||
|
||||
CrossPostDispatcher::dispatch($article, $url, 'com_content.article');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch cross-post when article state changes to published.
|
||||
*
|
||||
* Joomla 5/6 compatible — accepts both ContentChangeStateEvent and legacy parameters.
|
||||
*/
|
||||
public function onContentChangeState($event): void
|
||||
{
|
||||
if ($event instanceof \Joomla\CMS\Event\Content\ContentChangeStateEvent) {
|
||||
$context = $event->getContext();
|
||||
$pks = $event->getPks();
|
||||
$value = $event->getValue();
|
||||
} else {
|
||||
$context = $event;
|
||||
$pks = func_get_arg(1);
|
||||
$value = func_get_arg(2);
|
||||
}
|
||||
|
||||
if ($context !== 'com_content.article' || $value !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$params = ComponentHelper::getParams('com_mokojoomcross');
|
||||
|
||||
if (!$params->get('auto_post_on_publish', 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
foreach ($pks as $pk) {
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from('#__content')
|
||||
->where('id = ' . (int) $pk);
|
||||
$db->setQuery($query);
|
||||
$article = $db->loadObject();
|
||||
|
||||
if (!$article) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id;
|
||||
|
||||
if (!empty($article->catid)) {
|
||||
$url .= '&catid=' . $article->catid;
|
||||
}
|
||||
|
||||
CrossPostDispatcher::dispatch($article, $url, 'com_content.article');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,4 +104,9 @@ class ActivitypubService extends CMSPlugin implements SubscriberInterface, MokoJ
|
||||
|
||||
return ['valid' => false, 'message' => $data['error'] ?? 'Failed to verify credentials.', 'account_name' => ''];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,4 +107,9 @@ class BloggerService extends CMSPlugin implements SubscriberInterface, MokoJoomC
|
||||
|
||||
return ['valid' => false, 'message' => $data['error']['message'] ?? 'Failed to verify credentials.', 'account_name' => ''];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,4 +127,9 @@ class BlueskyService extends CMSPlugin implements SubscriberInterface, MokoJoomC
|
||||
|
||||
return json_decode($response, true) ?: [];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,4 +84,9 @@ class BrevoService extends CMSPlugin implements SubscriberInterface, MokoJoomCro
|
||||
|
||||
return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Brevo (Sendinblue)'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -84,4 +84,9 @@ class ConstantcontactService extends CMSPlugin implements SubscriberInterface, M
|
||||
|
||||
return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Constant Contact'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,4 +84,9 @@ class ConvertkitService extends CMSPlugin implements SubscriberInterface, MokoJo
|
||||
|
||||
return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'ConvertKit'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,4 +84,9 @@ class DevtoService extends CMSPlugin implements SubscriberInterface, MokoJoomCro
|
||||
|
||||
return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Dev.to'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,4 +115,9 @@ class DiscordService extends CMSPlugin implements SubscriberInterface, MokoJoomC
|
||||
|
||||
return $this->params->get('default_webhook_url', '');
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,4 +141,9 @@ class FacebookService extends CMSPlugin implements SubscriberInterface, MokoJoom
|
||||
|
||||
return $this->params->get('default_page_access_token', '');
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video', 'gif'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,4 +158,9 @@ class GhostService extends CMSPlugin implements SubscriberInterface, MokoJoomCro
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,4 +112,9 @@ class GoogleBusinessService extends CMSPlugin implements SubscriberInterface, Mo
|
||||
|
||||
return ['valid' => false, 'message' => $data['error']['message'] ?? 'Failed to verify credentials.', 'account_name' => ''];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,4 +85,9 @@ class GoogleChatService extends CMSPlugin implements SubscriberInterface, MokoJo
|
||||
|
||||
return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Google Chat'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,4 +125,9 @@ class HashnodeService extends CMSPlugin implements SubscriberInterface, MokoJoom
|
||||
|
||||
return ['valid' => false, 'message' => 'Failed to verify token.', 'account_name' => ''];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,4 +131,9 @@ class LinkedinService extends CMSPlugin implements SubscriberInterface, MokoJoom
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,4 +142,9 @@ class MailchimpService extends CMSPlugin implements SubscriberInterface, MokoJoo
|
||||
|
||||
return end($parts) ?: 'us1';
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,4 +99,9 @@ class MastodonService extends CMSPlugin implements SubscriberInterface, MokoJoom
|
||||
|
||||
return ['valid' => false, 'message' => 'Failed', 'account_name' => ''];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video', 'gif'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,4 +115,9 @@ class MatrixService extends CMSPlugin implements SubscriberInterface, MokoJoomCr
|
||||
|
||||
return ['valid' => false, 'message' => $data['error'] ?? 'Failed to verify credentials.', 'account_name' => ''];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,4 +133,9 @@ class MediumService extends CMSPlugin implements SubscriberInterface, MokoJoomCr
|
||||
|
||||
return $data['data']['id'] ?? '';
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
; MokoJoomCross - MokoJoomCalendar Events Service
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR="MokoJoomCross - MokoJoomCalendar Events"
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_DESCRIPTION="Cross-posts MokoJoomCalendar events to connected platforms. Enriches messages with event date, time, location, and calendar details."
|
||||
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_FIELDSET_DEFAULTS="Event Cross-Post Settings"
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_INCLUDE_LOCATION="Include Location"
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_INCLUDE_LOCATION_DESC="Append the event location to the cross-post message."
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_INCLUDE_DATE="Include Date/Time"
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_INCLUDE_DATE_DESC="Append the event date and time to the cross-post message."
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_DATE_FORMAT="Date Format"
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_DATE_FORMAT_DESC="PHP date format string for event dates. Default: l, F j, Y at g:ia"
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
; MokoJoomCross - MokoJoomCalendar Events Service
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR="Plugin - MokoJoomCross MokoJoomCalendar Events"
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_DESCRIPTION="Cross-posts MokoJoomCalendar events to connected platforms."
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomCross
|
||||
* @subpackage plg_mokojoomcross_mokojoomcalendar
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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
|
||||
*
|
||||
* Legacy entry point — required by Joomla's plugin loader.
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokojoomcross" method="upgrade">
|
||||
<name>MokoJoomCross - MokoJoomCalendar Events</name>
|
||||
<version>01.00.06-dev</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<description>PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\MokoJoomCross\MokoJoomCalendar</namespace>
|
||||
|
||||
<files>
|
||||
<filename plugin="mokojoomcalendar">mokojoomcalendar.php</filename>
|
||||
<folder>src</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
</files>
|
||||
|
||||
<languages>
|
||||
<language tag="en-GB">language/en-GB/plg_mokojoomcross_mokojoomcalendar.ini</language>
|
||||
<language tag="en-GB">language/en-GB/plg_mokojoomcross_mokojoomcalendar.sys.ini</language>
|
||||
</languages>
|
||||
|
||||
<config>
|
||||
<fields name="params">
|
||||
<fieldset name="basic" label="PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_FIELDSET_DEFAULTS">
|
||||
<field
|
||||
name="include_location"
|
||||
type="radio"
|
||||
label="PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_INCLUDE_LOCATION"
|
||||
description="PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_INCLUDE_LOCATION_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="include_date"
|
||||
type="radio"
|
||||
label="PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_INCLUDE_DATE"
|
||||
description="PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_INCLUDE_DATE_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="date_format"
|
||||
type="text"
|
||||
label="PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_DATE_FORMAT"
|
||||
description="PLG_MOKOJOOMCROSS_MOKOJOOMCALENDAR_DATE_FORMAT_DESC"
|
||||
default="l, F j, Y \a\t g:ia"
|
||||
/>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomCross
|
||||
* @subpackage plg_mokojoomcross_mokojoomcalendar
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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\MokoJoomCross\MokoJoomCalendar\Extension\CalendarService;
|
||||
|
||||
return new class () implements ServiceProviderInterface {
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->set(
|
||||
PluginInterface::class,
|
||||
function (Container $container) {
|
||||
$plugin = new CalendarService(
|
||||
$container->get(DispatcherInterface::class),
|
||||
(array) PluginHelper::getPlugin('mokojoomcross', 'mokojoomcalendar')
|
||||
);
|
||||
$plugin->setApplication(Factory::getApplication());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomCross
|
||||
* @subpackage plg_mokojoomcross_mokojoomcalendar
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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\MokoJoomCross\MokoJoomCalendar\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
/**
|
||||
* MokoJoomCalendar service plugin for MokoJoomCross.
|
||||
*
|
||||
* Cross-posts calendar events when they are published.
|
||||
* Enriches the message with event date, time, location, and calendar name.
|
||||
*
|
||||
* Credentials format:
|
||||
* {
|
||||
* "site_url": "https://example.com" // Optional, defaults to current site
|
||||
* }
|
||||
*/
|
||||
class CalendarService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices'];
|
||||
}
|
||||
|
||||
public function onMokoJoomCrossGetServices(&$services): void
|
||||
{
|
||||
$services[] = $this;
|
||||
}
|
||||
|
||||
public function getServiceType(): string
|
||||
{
|
||||
return 'mokojoomcalendar';
|
||||
}
|
||||
|
||||
public function getServiceName(): string
|
||||
{
|
||||
return 'MokoJoomCalendar Events';
|
||||
}
|
||||
|
||||
public function getMaxLength(): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function supportsMedia(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function publish(string $message, array $media, array $credentials, array $params): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$articleId = (int) ($params['_article_id'] ?? 0);
|
||||
|
||||
if (!$articleId) {
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'No article ID']];
|
||||
}
|
||||
|
||||
// Load the event data from mokojoomcalendar
|
||||
$query = $db->getQuery(true)
|
||||
->select('e.*, c.title AS calendar_title, c.color AS calendar_color, l.title AS location_title, l.address, l.city, l.state')
|
||||
->from($db->quoteName('#__mokojoomcalendar_events', 'e'))
|
||||
->leftJoin($db->quoteName('#__mokojoomcalendar_calendars', 'c') . ' ON c.id = e.calendar_id')
|
||||
->leftJoin($db->quoteName('#__mokojoomcalendar_locations', 'l') . ' ON l.id = e.location_id')
|
||||
->where($db->quoteName('e.id') . ' = ' . $articleId);
|
||||
|
||||
$db->setQuery($query);
|
||||
$event = $db->loadObject();
|
||||
|
||||
if (!$event) {
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Event not found']];
|
||||
}
|
||||
|
||||
// Enrich the message with event metadata
|
||||
$enriched = $this->enrichMessage($message, $event, $params);
|
||||
|
||||
// Build media array from event image
|
||||
if (empty($media) && !empty($event->image)) {
|
||||
$siteUrl = rtrim($credentials['site_url'] ?? Uri::root(), '/');
|
||||
$media = [$siteUrl . '/' . ltrim($event->image, '/')];
|
||||
}
|
||||
|
||||
// Store enriched post internally (this plugin creates queue entries for other services)
|
||||
return [
|
||||
'success' => true,
|
||||
'platform_post_id' => 'event-' . $event->id,
|
||||
'response' => [
|
||||
'event_id' => (int) $event->id,
|
||||
'event_title' => $event->title,
|
||||
'calendar' => $event->calendar_title,
|
||||
'start_date' => $event->start_date,
|
||||
'end_date' => $event->end_date,
|
||||
'location' => $event->location_title,
|
||||
'enriched_message' => $enriched,
|
||||
'media' => $media,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function validateCredentials(array $credentials): array
|
||||
{
|
||||
// Check if com_mokojoomcalendar is installed
|
||||
if (!file_exists(JPATH_ADMINISTRATOR . '/components/com_mokojoomcalendar')) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'message' => 'MokoJoomCalendar component is not installed.',
|
||||
'account_name' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokojoomcalendar_events'));
|
||||
$db->setQuery($query);
|
||||
$count = (int) $db->loadResult();
|
||||
|
||||
return [
|
||||
'valid' => true,
|
||||
'message' => "Connected. {$count} event(s) in database.",
|
||||
'account_name' => 'MokoJoomCalendar',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich the cross-post message with event-specific data.
|
||||
*/
|
||||
private function enrichMessage(string $message, object $event, array $params): string
|
||||
{
|
||||
$parts = [$message];
|
||||
|
||||
// Add date/time
|
||||
if ((int) ($params['include_date'] ?? $this->params->get('include_date', 1))) {
|
||||
$format = $params['date_format'] ?? $this->params->get('date_format', 'l, F j, Y \\a\\t g:ia');
|
||||
$startDate = Factory::getDate($event->start_date);
|
||||
$dateStr = $startDate->format($format);
|
||||
|
||||
if (!empty($event->end_date) && $event->end_date !== $event->start_date) {
|
||||
$endDate = Factory::getDate($event->end_date);
|
||||
$dateStr .= ' — ' . $endDate->format($format);
|
||||
}
|
||||
|
||||
if (!empty($event->all_day)) {
|
||||
$dateStr = $startDate->format('l, F j, Y') . ' (All Day)';
|
||||
}
|
||||
|
||||
$parts[] = $dateStr;
|
||||
}
|
||||
|
||||
// Add location
|
||||
if ((int) ($params['include_location'] ?? $this->params->get('include_location', 1))) {
|
||||
$locationParts = array_filter([
|
||||
$event->location_title ?? '',
|
||||
$event->address ?? '',
|
||||
$event->city ?? '',
|
||||
$event->state ?? '',
|
||||
]);
|
||||
|
||||
if (!empty($locationParts)) {
|
||||
$parts[] = implode(', ', $locationParts);
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n\n", array_filter($parts));
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
; MokoJoomCross - MokoJoomGallery Service
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY="MokoJoomCross - MokoJoomGallery"
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_DESCRIPTION="Cross-posts MokoJoomGallery content to connected platforms. Supports gallery announcements with preview images and individual image posts."
|
||||
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_FIELDSET_DEFAULTS="Gallery Cross-Post Settings"
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_POST_MODE="Post Mode"
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_POST_MODE_DESC="Gallery mode posts when a gallery is published (with preview images). Image mode posts each individual image."
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_MODE_GALLERY="Gallery (with preview images)"
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_MODE_IMAGE="Individual Images"
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_MAX_IMAGES="Max Preview Images"
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_MAX_IMAGES_DESC="Maximum number of preview images to attach when cross-posting a gallery."
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_INCLUDE_DESCRIPTION="Include Description"
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_INCLUDE_DESCRIPTION_DESC="Append the gallery or image description to the cross-post message."
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
; MokoJoomCross - MokoJoomGallery Service
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY="Plugin - MokoJoomCross MokoJoomGallery"
|
||||
PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_DESCRIPTION="Cross-posts MokoJoomGallery galleries and images to connected platforms."
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomCross
|
||||
* @subpackage plg_mokojoomcross_mokojoomgallery
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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
|
||||
*
|
||||
* Legacy entry point — required by Joomla's plugin loader.
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="mokojoomcross" method="upgrade">
|
||||
<name>MokoJoomCross - MokoJoomGallery</name>
|
||||
<version>01.00.06-dev</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<description>PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\MokoJoomCross\MokoJoomGallery</namespace>
|
||||
|
||||
<files>
|
||||
<filename plugin="mokojoomgallery">mokojoomgallery.php</filename>
|
||||
<folder>src</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
</files>
|
||||
|
||||
<languages>
|
||||
<language tag="en-GB">language/en-GB/plg_mokojoomcross_mokojoomgallery.ini</language>
|
||||
<language tag="en-GB">language/en-GB/plg_mokojoomcross_mokojoomgallery.sys.ini</language>
|
||||
</languages>
|
||||
|
||||
<config>
|
||||
<fields name="params">
|
||||
<fieldset name="basic" label="PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_FIELDSET_DEFAULTS">
|
||||
<field
|
||||
name="post_mode"
|
||||
type="list"
|
||||
label="PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_POST_MODE"
|
||||
description="PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_POST_MODE_DESC"
|
||||
default="gallery"
|
||||
>
|
||||
<option value="gallery">PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_MODE_GALLERY</option>
|
||||
<option value="image">PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_MODE_IMAGE</option>
|
||||
</field>
|
||||
<field
|
||||
name="max_images"
|
||||
type="number"
|
||||
label="PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_MAX_IMAGES"
|
||||
description="PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_MAX_IMAGES_DESC"
|
||||
default="4"
|
||||
min="1"
|
||||
max="20"
|
||||
/>
|
||||
<field
|
||||
name="include_description"
|
||||
type="radio"
|
||||
label="PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_INCLUDE_DESCRIPTION"
|
||||
description="PLG_MOKOJOOMCROSS_MOKOJOOMGALLERY_INCLUDE_DESCRIPTION_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomCross
|
||||
* @subpackage plg_mokojoomcross_mokojoomgallery
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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\MokoJoomCross\MokoJoomGallery\Extension\GalleryService;
|
||||
|
||||
return new class () implements ServiceProviderInterface {
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->set(
|
||||
PluginInterface::class,
|
||||
function (Container $container) {
|
||||
$plugin = new GalleryService(
|
||||
$container->get(DispatcherInterface::class),
|
||||
(array) PluginHelper::getPlugin('mokojoomcross', 'mokojoomgallery')
|
||||
);
|
||||
$plugin->setApplication(Factory::getApplication());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomCross
|
||||
* @subpackage plg_mokojoomcross_mokojoomgallery
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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\MokoJoomCross\MokoJoomGallery\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
/**
|
||||
* MokoJoomGallery service plugin for MokoJoomCross.
|
||||
*
|
||||
* Cross-posts gallery content when new images or galleries are published.
|
||||
* Two modes:
|
||||
* - "gallery" mode: posts when a gallery is created/published (includes preview images)
|
||||
* - "image" mode: posts each individual image when published
|
||||
*
|
||||
* Credentials format:
|
||||
* {
|
||||
* "site_url": "https://example.com" // Optional, defaults to current site
|
||||
* }
|
||||
*/
|
||||
class GalleryService extends CMSPlugin implements SubscriberInterface, MokoJoomCrossServiceInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return ['onMokoJoomCrossGetServices' => 'onMokoJoomCrossGetServices'];
|
||||
}
|
||||
|
||||
public function onMokoJoomCrossGetServices(&$services): void
|
||||
{
|
||||
$services[] = $this;
|
||||
}
|
||||
|
||||
public function getServiceType(): string
|
||||
{
|
||||
return 'mokojoomgallery';
|
||||
}
|
||||
|
||||
public function getServiceName(): string
|
||||
{
|
||||
return 'MokoJoomGallery';
|
||||
}
|
||||
|
||||
public function getMaxLength(): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function supportsMedia(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function publish(string $message, array $media, array $credentials, array $params): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$siteUrl = rtrim($credentials['site_url'] ?? Uri::root(), '/');
|
||||
$mode = $params['post_mode'] ?? $this->params->get('post_mode', 'gallery');
|
||||
|
||||
$articleId = (int) ($params['_article_id'] ?? 0);
|
||||
|
||||
if (!$articleId) {
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'No content ID']];
|
||||
}
|
||||
|
||||
if ($mode === 'image') {
|
||||
return $this->publishImage($articleId, $message, $media, $siteUrl, $params);
|
||||
}
|
||||
|
||||
return $this->publishGallery($articleId, $message, $media, $siteUrl, $params);
|
||||
}
|
||||
|
||||
public function validateCredentials(array $credentials): array
|
||||
{
|
||||
if (!file_exists(JPATH_ADMINISTRATOR . '/components/com_mokojoomgallery')) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'message' => 'MokoJoomGallery component is not installed.',
|
||||
'account_name' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokojoomgallery_galleries'));
|
||||
$db->setQuery($query);
|
||||
$galleries = (int) $db->loadResult();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokojoomgallery_images'));
|
||||
$db->setQuery($query);
|
||||
$images = (int) $db->loadResult();
|
||||
|
||||
return [
|
||||
'valid' => true,
|
||||
'message' => "Connected. {$galleries} gallery(ies), {$images} image(s).",
|
||||
'account_name' => 'MokoJoomGallery',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-post a gallery with preview images.
|
||||
*/
|
||||
private function publishGallery(int $galleryId, string $message, array $media, string $siteUrl, array $params): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokojoomgallery_galleries'))
|
||||
->where($db->quoteName('id') . ' = ' . $galleryId);
|
||||
$db->setQuery($query);
|
||||
$gallery = $db->loadObject();
|
||||
|
||||
if (!$gallery) {
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Gallery not found']];
|
||||
}
|
||||
|
||||
// Get preview images
|
||||
$maxImages = (int) ($params['max_images'] ?? $this->params->get('max_images', 4));
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['thumbnail', 'original', 'title']))
|
||||
->from($db->quoteName('#__mokojoomgallery_images'))
|
||||
->where($db->quoteName('gallery_id') . ' = ' . $galleryId)
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC')
|
||||
->setLimit($maxImages);
|
||||
$db->setQuery($query);
|
||||
$images = $db->loadObjectList();
|
||||
|
||||
// Build media URLs from gallery images
|
||||
if (empty($media) && !empty($images)) {
|
||||
$media = [];
|
||||
|
||||
foreach ($images as $img) {
|
||||
$path = $img->thumbnail ?: $img->original;
|
||||
|
||||
if ($path) {
|
||||
$media[] = $siteUrl . '/' . ltrim($path, '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich message with gallery info
|
||||
$enriched = $message;
|
||||
|
||||
if ((int) ($params['include_description'] ?? $this->params->get('include_description', 1))) {
|
||||
$desc = strip_tags($gallery->description ?? '');
|
||||
|
||||
if ($desc !== '') {
|
||||
$enriched .= "\n\n" . mb_substr($desc, 0, 200);
|
||||
}
|
||||
}
|
||||
|
||||
$imageCount = $db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokojoomgallery_images'))
|
||||
->where($db->quoteName('gallery_id') . ' = ' . $galleryId)
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
)->loadResult();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'platform_post_id' => 'gallery-' . $galleryId,
|
||||
'response' => [
|
||||
'gallery_id' => $galleryId,
|
||||
'gallery_title' => $gallery->title,
|
||||
'image_count' => (int) $imageCount,
|
||||
'preview_count' => count($images),
|
||||
'enriched_message' => $enriched,
|
||||
'media' => $media,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-post a single image.
|
||||
*/
|
||||
private function publishImage(int $imageId, string $message, array $media, string $siteUrl, array $params): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('i.*, g.title AS gallery_title')
|
||||
->from($db->quoteName('#__mokojoomgallery_images', 'i'))
|
||||
->leftJoin($db->quoteName('#__mokojoomgallery_galleries', 'g') . ' ON g.id = i.gallery_id')
|
||||
->where($db->quoteName('i.id') . ' = ' . $imageId);
|
||||
$db->setQuery($query);
|
||||
$image = $db->loadObject();
|
||||
|
||||
if (!$image) {
|
||||
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Image not found']];
|
||||
}
|
||||
|
||||
// Use image as media
|
||||
if (empty($media)) {
|
||||
$path = $image->original ?: $image->thumbnail;
|
||||
|
||||
if ($path) {
|
||||
$media = [$siteUrl . '/' . ltrim($path, '/')];
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich with image description
|
||||
$enriched = $message;
|
||||
|
||||
if ((int) ($params['include_description'] ?? $this->params->get('include_description', 1))) {
|
||||
$desc = strip_tags($image->description ?? '');
|
||||
|
||||
if ($desc !== '') {
|
||||
$enriched .= "\n\n" . mb_substr($desc, 0, 200);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'platform_post_id' => 'image-' . $imageId,
|
||||
'response' => [
|
||||
'image_id' => $imageId,
|
||||
'image_title' => $image->title,
|
||||
'gallery_title' => $image->gallery_title,
|
||||
'enriched_message' => $enriched,
|
||||
'media' => $media,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video'];
|
||||
}
|
||||
}
|
||||
@@ -89,4 +89,9 @@ class NostrService extends CMSPlugin implements SubscriberInterface, MokoJoomCro
|
||||
|
||||
return ['valid' => true, 'message' => 'Credentials configured (' . count($relayList) . ' relay(s))', 'account_name' => 'Nostr'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,4 +93,9 @@ class NtfyService extends CMSPlugin implements SubscriberInterface, MokoJoomCros
|
||||
|
||||
return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Ntfy Push Notifications'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,4 +84,9 @@ class PinterestService extends CMSPlugin implements SubscriberInterface, MokoJoo
|
||||
|
||||
return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Pinterest'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,4 +84,9 @@ class RedditService extends CMSPlugin implements SubscriberInterface, MokoJoomCr
|
||||
|
||||
return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Reddit'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,4 +58,9 @@ class RssfeedService extends CMSPlugin implements SubscriberInterface, MokoJoomC
|
||||
// No credentials required for local RSS feed generation.
|
||||
return ['valid' => true, 'message' => 'RSS feed is a local service — no credentials needed.', 'account_name' => 'RSS Feed'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,4 +84,9 @@ class SendgridService extends CMSPlugin implements SubscriberInterface, MokoJoom
|
||||
|
||||
return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'SendGrid'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,4 +113,9 @@ class SlackService extends CMSPlugin implements SubscriberInterface, MokoJoomCro
|
||||
|
||||
return $this->params->get('default_webhook_url', '');
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,4 +96,9 @@ class TeamsService extends CMSPlugin implements SubscriberInterface, MokoJoomCro
|
||||
return $this->params->get('default_webhook_url', '');
|
||||
}
|
||||
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,4 +184,9 @@ class TelegramService extends CMSPlugin implements SubscriberInterface, MokoJoom
|
||||
// Default mode — load from plugin params (set in Extensions → Plugins → MokoJoomCross - Telegram)
|
||||
return $this->params->get('default_bot_token', '');
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video', 'document'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,4 +143,9 @@ class ThreadsService extends CMSPlugin implements SubscriberInterface, MokoJoomC
|
||||
|
||||
return $this->params->get('default_' . $key, '');
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,4 +84,9 @@ class TiktokService extends CMSPlugin implements SubscriberInterface, MokoJoomCr
|
||||
|
||||
return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'TikTok'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,4 +110,9 @@ class TumblrService extends CMSPlugin implements SubscriberInterface, MokoJoomCr
|
||||
|
||||
return ['valid' => false, 'message' => $data['meta']['msg'] ?? 'Failed to verify credentials.', 'account_name' => ''];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video', 'gif'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,4 +182,9 @@ class TwitterService extends CMSPlugin implements SubscriberInterface, MokoJoomC
|
||||
|
||||
return 'OAuth ' . implode(', ', $parts);
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video', 'gif'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,4 +107,9 @@ class WebhookService extends CMSPlugin implements SubscriberInterface, MokoJoomC
|
||||
|
||||
return ['valid' => true, 'message' => 'Credentials configured', 'account_name' => 'Generic Webhook'];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video', 'document'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,4 +118,9 @@ class WhatsappService extends CMSPlugin implements SubscriberInterface, MokoJoom
|
||||
|
||||
return ['valid' => false, 'message' => $data['error']['message'] ?? 'Failed to verify credentials.', 'account_name' => ''];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image', 'video', 'document'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,9 +53,20 @@ class WordpressService extends CMSPlugin implements SubscriberInterface, MokoJoo
|
||||
$apiUrl = $siteUrl . '/wp-json/wp/v2/posts';
|
||||
$title = mb_substr(strip_tags($message), 0, 200);
|
||||
|
||||
// Append source link if the original article URL is available
|
||||
$articleUrl = $params['_article_url'] ?? '';
|
||||
$content = $message;
|
||||
|
||||
if (!empty($articleUrl)) {
|
||||
$content .= "\n\n<p><em>Originally published at <a href=\""
|
||||
. htmlspecialchars($articleUrl, ENT_QUOTES, 'UTF-8') . '">'
|
||||
. htmlspecialchars($articleUrl, ENT_QUOTES, 'UTF-8')
|
||||
. '</a></em></p>';
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'title' => $title,
|
||||
'content' => $message,
|
||||
'content' => $content,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
@@ -112,4 +123,9 @@ class WordpressService extends CMSPlugin implements SubscriberInterface, MokoJoo
|
||||
|
||||
return ['valid' => false, 'message' => $data['message'] ?? 'Failed to verify credentials.', 'account_name' => ''];
|
||||
}
|
||||
|
||||
public function getSupportedMediaTypes(): array
|
||||
{
|
||||
return ['image'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,33 +16,25 @@ defined('_JEXEC') or die;
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
/**
|
||||
* System plugin that triggers cross-posting when Joomla articles are published.
|
||||
* System plugin that handles page-load queue processing for MokoJoomCross.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Article saved → onContentAfterSave fires
|
||||
* 2. Check: is it a com_content article? Is it published? Is auto-post enabled?
|
||||
* 3. Load enabled services from #__mokojoomcross_services
|
||||
* 4. Skip services that already have a post for this article (duplicate guard)
|
||||
* 5. Render message template with article placeholders
|
||||
* 6. Queue post record, then immediately attempt dispatch to the service plugin
|
||||
* 7. Service plugin calls the platform API and returns success/failure
|
||||
* 8. Update post status and log the result
|
||||
* When queue processing mode is set to "pageload" or "both", this plugin
|
||||
* processes a small batch of queued cross-posts on each page render,
|
||||
* throttled to a configurable interval (default 5 minutes).
|
||||
*
|
||||
* Content-type event handlers (articles, calendar events, gallery items)
|
||||
* are handled by their respective plugins, which delegate to the
|
||||
* CrossPostDispatcher helper for dispatch logic.
|
||||
*/
|
||||
class MokoJoomCross extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'onContentAfterSave' => 'onContentAfterSave',
|
||||
'onContentChangeState' => 'onContentChangeState',
|
||||
'onAfterRender' => 'onAfterRender',
|
||||
'onAfterRender' => 'onAfterRender',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -117,358 +109,4 @@ class MokoJoomCross extends CMSPlugin implements SubscriberInterface
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered after a content item is saved.
|
||||
*/
|
||||
public function onContentAfterSave(string $context, $article, bool $isNew): void
|
||||
{
|
||||
if ($context !== 'com_content.article') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) ($article->state ?? 0) !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$componentParams = ComponentHelper::getParams('com_mokojoomcross');
|
||||
|
||||
if (!$componentParams->get('auto_post_on_publish', 1)) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when article state changes (e.g. unpublished → published via list toggle).
|
||||
*/
|
||||
public function onContentChangeState(string $context, array $pks, int $value): void
|
||||
{
|
||||
if ($context !== 'com_content.article' || $value !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$componentParams = ComponentHelper::getParams('com_mokojoomcross');
|
||||
|
||||
if (!$componentParams->get('auto_post_on_publish', 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
foreach ($pks as $pk) {
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $pk);
|
||||
$db->setQuery($query);
|
||||
$article = $db->loadObject();
|
||||
|
||||
if ($article) {
|
||||
$this->dispatchCrossPost($article);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch article to all enabled service plugins.
|
||||
*/
|
||||
private function dispatchCrossPost(object $article): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load all enabled services
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokojoomcross_services'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
$services = $db->loadObjectList();
|
||||
|
||||
if (empty($services)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Import service plugins so they register with the dispatcher
|
||||
PluginHelper::importPlugin('mokojoomcross');
|
||||
|
||||
// Collect registered service plugin instances
|
||||
$servicePlugins = [];
|
||||
$this->getApplication()->getDispatcher()->dispatch(
|
||||
'onMokoJoomCrossGetServices',
|
||||
new \Joomla\Event\Event('onMokoJoomCrossGetServices', [&$servicePlugins])
|
||||
);
|
||||
|
||||
// Index by service type for lookup
|
||||
$pluginMap = [];
|
||||
|
||||
foreach ($servicePlugins as $plugin) {
|
||||
if ($plugin instanceof MokoJoomCrossServiceInterface) {
|
||||
$pluginMap[$plugin->getServiceType()] = $plugin;
|
||||
}
|
||||
}
|
||||
|
||||
$componentParams = ComponentHelper::getParams('com_mokojoomcross');
|
||||
$maxRetry = (int) $componentParams->get('retry_max', 3);
|
||||
|
||||
// Per-article selective cross-posting (#19)
|
||||
// If article attribs contain mokojoomcross_services, only post to those service IDs.
|
||||
// If mokojoomcross_skip is set, skip cross-posting entirely.
|
||||
$attribs = json_decode($article->attribs ?? '{}', true) ?: [];
|
||||
$selectedServiceIds = $attribs['mokojoomcross_services'] ?? null;
|
||||
$skipCrossPost = !empty($attribs['mokojoomcross_skip']);
|
||||
|
||||
if ($skipCrossPost) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If specific services selected, convert to array of ints for filtering
|
||||
if (is_array($selectedServiceIds) && !empty($selectedServiceIds)) {
|
||||
$selectedServiceIds = array_map('intval', $selectedServiceIds);
|
||||
} else {
|
||||
$selectedServiceIds = null; // null = post to all
|
||||
}
|
||||
|
||||
foreach ($services as $service) {
|
||||
// Per-article filter: skip if article specifies services and this one isn't in the list
|
||||
if ($selectedServiceIds !== null && !in_array((int) $service->id, $selectedServiceIds, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Duplicate guard — skip if article already posted/queued for this service
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokojoomcross_posts'))
|
||||
->where($db->quoteName('article_id') . ' = ' . (int) $article->id)
|
||||
->where($db->quoteName('service_id') . ' = ' . (int) $service->id)
|
||||
->where($db->quoteName('status') . ' IN (' . $db->quote('queued') . ',' . $db->quote('posted') . ',' . $db->quote('posting') . ')');
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
if ((int) $db->loadResult() > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$message = $this->renderTemplate($article, $service);
|
||||
|
||||
// Create queue entry
|
||||
$post = (object) [
|
||||
'article_id' => (int) $article->id,
|
||||
'service_id' => (int) $service->id,
|
||||
'status' => 'queued',
|
||||
'message' => $message,
|
||||
'platform_post_id' => '',
|
||||
'platform_response' => '',
|
||||
'error_message' => '',
|
||||
'retry_count' => 0,
|
||||
'created' => Factory::getDate()->toSql(),
|
||||
'modified' => Factory::getDate()->toSql(),
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokojoomcross_posts', $post);
|
||||
$postId = $db->insertid();
|
||||
|
||||
// Attempt immediate dispatch if service plugin is available
|
||||
$plugin = $pluginMap[$service->service_type] ?? null;
|
||||
|
||||
if ($plugin) {
|
||||
$this->executePost($db, $postId, $plugin, $message, $service);
|
||||
} else {
|
||||
$this->log($db, $postId, $service->id, 'warning',
|
||||
sprintf('No service plugin found for type "%s" — post remains queued', $service->service_type));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a cross-post via the service plugin.
|
||||
*/
|
||||
private function executePost($db, int $postId, MokoJoomCrossServiceInterface $plugin, string $message, object $service): void
|
||||
{
|
||||
// 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') . ' = ' . $postId)
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
$credentials = json_decode($service->credentials ?: '{}', true) ?: [];
|
||||
$params = json_decode($service->params ?: '{}', true) ?: [];
|
||||
|
||||
try {
|
||||
$result = $plugin->publish($message, [], $credentials, $params);
|
||||
|
||||
if (!empty($result['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($result['platform_post_id'] ?? ''))
|
||||
->set($db->quoteName('platform_response') . ' = ' . $db->quote(json_encode($result['response'] ?? [])))
|
||||
->set($db->quoteName('posted_at') . ' = ' . $db->quote(Factory::getDate()->toSql()))
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql()))
|
||||
->where($db->quoteName('id') . ' = ' . $postId)
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
$this->log($db, $postId, $service->id, 'info',
|
||||
sprintf('Posted to %s (platform ID: %s)', $service->service_type, $result['platform_post_id'] ?? 'n/a'));
|
||||
} else {
|
||||
$errorMsg = $result['response']['error'] ?? json_encode($result['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($result['response'] ?? [])))
|
||||
->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql()))
|
||||
->where($db->quoteName('id') . ' = ' . $postId)
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
$this->log($db, $postId, $service->id, 'error',
|
||||
sprintf('Failed to post to %s: %s', $service->service_type, $errorMsg));
|
||||
}
|
||||
} 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') . ' = ' . $postId)
|
||||
);
|
||||
$db->execute();
|
||||
|
||||
$this->log($db, $postId, $service->id, 'error',
|
||||
sprintf('Exception posting to %s: %s', $service->service_type, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the message template for a service.
|
||||
*/
|
||||
private function renderTemplate(object $article, object $service): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Try service-specific template first, fall back to default
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('template_body'))
|
||||
->from($db->quoteName('#__mokojoomcross_templates'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where('(' . $db->quoteName('service_type') . ' = ' . $db->quote($service->service_type)
|
||||
. ' OR ' . $db->quoteName('service_type') . ' = ' . $db->quote('default') . ')')
|
||||
->order('CASE WHEN ' . $db->quoteName('service_type') . ' = '
|
||||
. $db->quote($service->service_type) . ' THEN 0 ELSE 1 END')
|
||||
->setLimit(1);
|
||||
|
||||
$db->setQuery($query);
|
||||
$template = $db->loadResult() ?: "{title}\n\n{url}";
|
||||
|
||||
// Build SEF article URL
|
||||
$url = Uri::root() . 'index.php?option=com_content&view=article&id=' . $article->id;
|
||||
|
||||
if (!empty($article->catid)) {
|
||||
$url .= '&catid=' . $article->catid;
|
||||
}
|
||||
|
||||
// Resolve category name
|
||||
$categoryName = '';
|
||||
|
||||
if (!empty($article->catid)) {
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('title'))
|
||||
->from($db->quoteName('#__categories'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $article->catid);
|
||||
$db->setQuery($query);
|
||||
$categoryName = $db->loadResult() ?: '';
|
||||
}
|
||||
|
||||
// Resolve author name
|
||||
$authorName = '';
|
||||
|
||||
if (!empty($article->created_by)) {
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('name'))
|
||||
->from($db->quoteName('#__users'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $article->created_by);
|
||||
$db->setQuery($query);
|
||||
$authorName = $db->loadResult() ?: '';
|
||||
}
|
||||
|
||||
// Extract intro image
|
||||
$introImage = '';
|
||||
$images = json_decode($article->images ?? '{}');
|
||||
|
||||
if (!empty($images->image_intro)) {
|
||||
$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 ?? '',
|
||||
'{introtext}' => strip_tags(mb_substr($article->introtext ?? '', 0, 280)),
|
||||
'{fulltext}' => strip_tags(mb_substr($article->fulltext ?? '', 0, 500)),
|
||||
'{url}' => $url,
|
||||
'{image}' => $introImage,
|
||||
'{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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an entry to the activity log.
|
||||
*/
|
||||
private 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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
PLG_SYSTEM_MOKOJOOMCROSS_EVENTS="System - MokoJoomCross Events"
|
||||
PLG_SYSTEM_MOKOJOOMCROSS_EVENTS_DESCRIPTION="Cross-posts MokoJoomCalendar events to social media and messaging platforms via MokoJoomCross."
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
PLG_SYSTEM_MOKOJOOMCROSS_EVENTS="System - MokoJoomCross Events"
|
||||
PLG_SYSTEM_MOKOJOOMCROSS_EVENTS_DESCRIPTION="Cross-posts MokoJoomCalendar events to social media and messaging platforms via MokoJoomCross."
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomCross
|
||||
* @subpackage plg_system_mokojoomcross_events
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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;
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoJoomCross Events</name>
|
||||
<version>01.00.06-dev</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<description>PLG_SYSTEM_MOKOJOOMCROSS_EVENTS_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\System\MokoJoomCrossEvents</namespace>
|
||||
|
||||
<files>
|
||||
<filename plugin="mokojoomcross_events">mokojoomcross_events.php</filename>
|
||||
<folder>src</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
</files>
|
||||
|
||||
<languages>
|
||||
<language tag="en-GB">language/en-GB/plg_system_mokojoomcross_events.ini</language>
|
||||
<language tag="en-GB">language/en-GB/plg_system_mokojoomcross_events.sys.ini</language>
|
||||
</languages>
|
||||
</extension>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomCross
|
||||
* @subpackage plg_system_mokojoomcross_events
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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\System\MokoJoomCrossEvents\Extension\MokoJoomCrossEvents;
|
||||
|
||||
return new class () implements ServiceProviderInterface {
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->set(
|
||||
PluginInterface::class,
|
||||
function (Container $container) {
|
||||
$plugin = new MokoJoomCrossEvents(
|
||||
$container->get(DispatcherInterface::class),
|
||||
(array) PluginHelper::getPlugin('system', 'mokojoomcross_events')
|
||||
);
|
||||
$plugin->setApplication(Factory::getApplication());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomCross
|
||||
* @subpackage plg_system_mokojoomcross_events
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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\System\MokoJoomCrossEvents\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Component\MokoJoomCross\Administrator\Helper\CrossPostDispatcher;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
/**
|
||||
* System plugin that cross-posts MokoJoomCalendar events when published.
|
||||
*
|
||||
* Subscribes to the custom onMokoJoomCalendarEventAfterSave event fired by
|
||||
* MokoJoomCalendar and maps the calendar event to an article-like payload
|
||||
* for dispatch through MokoJoomCross services.
|
||||
*/
|
||||
class MokoJoomCrossEvents extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'onMokoJoomCalendarEventAfterSave' => 'onMokoJoomCalendarEventAfterSave',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-post calendar events when published.
|
||||
*/
|
||||
public function onMokoJoomCalendarEventAfterSave($event): void
|
||||
{
|
||||
// Check com_mokojoomcalendar is installed
|
||||
if (!file_exists(JPATH_ADMINISTRATOR . '/components/com_mokojoomcalendar')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$item = $event->getArgument('item');
|
||||
$isNew = $event->getArgument('isNew');
|
||||
|
||||
if ((int) ($item->published ?? 0) !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$componentParams = ComponentHelper::getParams('com_mokojoomcross');
|
||||
|
||||
if (!$componentParams->get('auto_post_on_publish', 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($componentParams->get('post_on_first_publish_only', 0) && !$isNew) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Map calendar event to article-like structure
|
||||
$url = Uri::root() . 'index.php?option=com_mokojoomcalendar&view=event&id=' . $item->id;
|
||||
|
||||
$article = (object) [
|
||||
'id' => (int) $item->id,
|
||||
'title' => $item->title ?? '',
|
||||
'introtext' => strip_tags(mb_substr($item->description ?? '', 0, 280)),
|
||||
'fulltext' => $item->description ?? '',
|
||||
'images' => !empty($item->image)
|
||||
? json_encode(['image_intro' => $item->image])
|
||||
: '{}',
|
||||
'state' => 1,
|
||||
'catid' => 0,
|
||||
'attribs' => $item->params ?? '{}',
|
||||
'publish_up' => $item->start_date ?? $item->created ?? '',
|
||||
'created_by' => $item->created_by ?? 0,
|
||||
'_content_type' => 'mokojoomcalendar',
|
||||
'_article_id' => (int) $item->id,
|
||||
'_article_url' => $url,
|
||||
];
|
||||
|
||||
CrossPostDispatcher::dispatch($article, $url, 'com_mokojoomcalendar.event');
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
PLG_SYSTEM_MOKOJOOMCROSS_GALLERY="System - MokoJoomCross Gallery"
|
||||
PLG_SYSTEM_MOKOJOOMCROSS_GALLERY_DESCRIPTION="Cross-posts MokoJoomGallery galleries and images to social media and messaging platforms via MokoJoomCross."
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
PLG_SYSTEM_MOKOJOOMCROSS_GALLERY="System - MokoJoomCross Gallery"
|
||||
PLG_SYSTEM_MOKOJOOMCROSS_GALLERY_DESCRIPTION="Cross-posts MokoJoomGallery galleries and images to social media and messaging platforms via MokoJoomCross."
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomCross
|
||||
* @subpackage plg_system_mokojoomcross_gallery
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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;
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoJoomCross Gallery</name>
|
||||
<version>01.00.06-dev</version>
|
||||
<creationDate>2026-05-28</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<description>PLG_SYSTEM_MOKOJOOMCROSS_GALLERY_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\System\MokoJoomCrossGallery</namespace>
|
||||
|
||||
<files>
|
||||
<filename plugin="mokojoomcross_gallery">mokojoomcross_gallery.php</filename>
|
||||
<folder>src</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
</files>
|
||||
|
||||
<languages>
|
||||
<language tag="en-GB">language/en-GB/plg_system_mokojoomcross_gallery.ini</language>
|
||||
<language tag="en-GB">language/en-GB/plg_system_mokojoomcross_gallery.sys.ini</language>
|
||||
</languages>
|
||||
</extension>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomCross
|
||||
* @subpackage plg_system_mokojoomcross_gallery
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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\System\MokoJoomCrossGallery\Extension\MokoJoomCrossGallery;
|
||||
|
||||
return new class () implements ServiceProviderInterface {
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->set(
|
||||
PluginInterface::class,
|
||||
function (Container $container) {
|
||||
$plugin = new MokoJoomCrossGallery(
|
||||
$container->get(DispatcherInterface::class),
|
||||
(array) PluginHelper::getPlugin('system', 'mokojoomcross_gallery')
|
||||
);
|
||||
$plugin->setApplication(Factory::getApplication());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomCross
|
||||
* @subpackage plg_system_mokojoomcross_gallery
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @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\System\MokoJoomCrossGallery\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\Component\MokoJoomCross\Administrator\Helper\CrossPostDispatcher;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
|
||||
/**
|
||||
* System plugin that cross-posts MokoJoomGallery galleries and images when published.
|
||||
*
|
||||
* Subscribes to custom events fired by MokoJoomGallery and maps gallery/image
|
||||
* items to article-like payloads for dispatch through MokoJoomCross services.
|
||||
*/
|
||||
class MokoJoomCrossGallery extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
'onMokoJoomGalleryGalleryAfterSave' => 'onMokoJoomGalleryGalleryAfterSave',
|
||||
'onMokoJoomGalleryImageAfterSave' => 'onMokoJoomGalleryImageAfterSave',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-post galleries when published.
|
||||
*/
|
||||
public function onMokoJoomGalleryGalleryAfterSave($event): void
|
||||
{
|
||||
if (!file_exists(JPATH_ADMINISTRATOR . '/components/com_mokojoomgallery')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$item = $event->getArgument('item');
|
||||
$isNew = $event->getArgument('isNew');
|
||||
|
||||
if ((int) ($item->published ?? 0) !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$componentParams = ComponentHelper::getParams('com_mokojoomcross');
|
||||
|
||||
if (!$componentParams->get('auto_post_on_publish', 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($componentParams->get('post_on_first_publish_only', 0) && !$isNew) {
|
||||
return;
|
||||
}
|
||||
|
||||
$url = Uri::root() . 'index.php?option=com_mokojoomgallery&view=category&id=' . $item->id;
|
||||
|
||||
$article = (object) [
|
||||
'id' => (int) $item->id,
|
||||
'title' => $item->title ?? '',
|
||||
'introtext' => strip_tags(mb_substr($item->description ?? '', 0, 280)),
|
||||
'fulltext' => $item->description ?? '',
|
||||
'images' => !empty($item->image)
|
||||
? json_encode(['image_intro' => $item->image])
|
||||
: '{}',
|
||||
'state' => 1,
|
||||
'catid' => 0,
|
||||
'attribs' => $item->params ?? '{}',
|
||||
'publish_up' => $item->created ?? '',
|
||||
'created_by' => $item->created_by ?? 0,
|
||||
'_content_type' => 'mokojoomgallery',
|
||||
'_article_id' => (int) $item->id,
|
||||
'_article_url' => $url,
|
||||
];
|
||||
|
||||
CrossPostDispatcher::dispatch($article, $url, 'com_mokojoomgallery.gallery');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-post individual images when published.
|
||||
*/
|
||||
public function onMokoJoomGalleryImageAfterSave($event): void
|
||||
{
|
||||
if (!file_exists(JPATH_ADMINISTRATOR . '/components/com_mokojoomgallery')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$item = $event->getArgument('item');
|
||||
$isNew = $event->getArgument('isNew');
|
||||
|
||||
if ((int) ($item->published ?? 0) !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$componentParams = ComponentHelper::getParams('com_mokojoomcross');
|
||||
|
||||
if (!$componentParams->get('auto_post_on_publish', 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($componentParams->get('post_on_first_publish_only', 0) && !$isNew) {
|
||||
return;
|
||||
}
|
||||
|
||||
$imagePath = $item->original ?? $item->thumbnail ?? '';
|
||||
|
||||
$url = Uri::root() . 'index.php?option=com_mokojoomgallery&view=category&id=' . ($item->gallery_id ?? 0);
|
||||
|
||||
$article = (object) [
|
||||
'id' => (int) $item->id,
|
||||
'title' => $item->title ?? '',
|
||||
'introtext' => strip_tags(mb_substr($item->description ?? '', 0, 280)),
|
||||
'fulltext' => $item->description ?? '',
|
||||
'images' => $imagePath
|
||||
? json_encode(['image_intro' => $imagePath])
|
||||
: '{}',
|
||||
'state' => 1,
|
||||
'catid' => 0,
|
||||
'attribs' => '{}',
|
||||
'publish_up' => $item->created ?? '',
|
||||
'created_by' => $item->created_by ?? 0,
|
||||
'_content_type' => 'mokojoomgallery',
|
||||
'_article_id' => (int) $item->id,
|
||||
'_article_url' => $url,
|
||||
];
|
||||
|
||||
CrossPostDispatcher::dispatch($article, $url, 'com_mokojoomgallery.image');
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ use Joomla\Event\SubscriberInterface;
|
||||
* /api/index.php/v1/mokojoomcross/services — CRUD services
|
||||
* /api/index.php/v1/mokojoomcross/templates — CRUD templates
|
||||
* /api/index.php/v1/mokojoomcross/logs — Read logs
|
||||
* /api/index.php/v1/mokojoomcross/dispatch — POST dispatch cross-posts for an article
|
||||
*/
|
||||
class MokoJoomCrossWebServices extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
@@ -42,5 +43,10 @@ class MokoJoomCrossWebServices extends CMSPlugin implements SubscriberInterface
|
||||
$router->createCRUDRoutes('v1/mokojoomcross/services', 'services', $defaults);
|
||||
$router->createCRUDRoutes('v1/mokojoomcross/templates', 'templates', $defaults);
|
||||
$router->createCRUDRoutes('v1/mokojoomcross/logs', 'logs', $defaults);
|
||||
|
||||
// Action endpoint: dispatch cross-posts for an article (POST only)
|
||||
$router->addRoute(
|
||||
new \Joomla\Router\Route(['POST'], 'v1/mokojoomcross/dispatch', 'dispatch.dispatch', [], $defaults)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@
|
||||
<file type="plugin" id="rssfeed" group="mokojoomcross">plg_mokojoomcross_rssfeed.zip</file>
|
||||
<file type="plugin" id="constantcontact" group="mokojoomcross">plg_mokojoomcross_constantcontact.zip</file>
|
||||
<file type="plugin" id="tiktok" group="mokojoomcross">plg_mokojoomcross_tiktok.zip</file>
|
||||
<file type="plugin" id="mokojoomcalendar" group="mokojoomcross">plg_mokojoomcross_mokojoomcalendar.zip</file>
|
||||
<file type="plugin" id="mokojoomgallery" group="mokojoomcross">plg_mokojoomcross_mokojoomgallery.zip</file>
|
||||
</files>
|
||||
|
||||
<languages>
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
<downloads>
|
||||
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCross/releases/download/development/pkg_mokojoomcross-01.00.06-dev-dev.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>4deb123f1472a1c076e267ad5da0cb4f9f9b7d844ff71b029e32a2125ee6963d</sha256>
|
||||
<sha256>e50ba6f989bdae385ddc7a9ce108fe9729feb7aff257b681c3cc9f4bb00c06eb</sha256>
|
||||
<tags><tag>dev</tag></tags>
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
|
||||
Reference in New Issue
Block a user