Compare commits

...

17 Commits

Author SHA1 Message Date
gitea-actions[bot] 40e540461e chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-29 05:29:01 +00:00
Jonathan Miller 8dd6fdd926 fix: critical and high severity audit fixes
Universal: Auto Version Bump / Version Bump (push) Successful in 4s
Update Server / Update Server (push) Successful in 10s
C-1: CSRF nonce on OAuth authorize/callback flow
C-2: POST method enforcement on REST dispatch endpoint
C-5: Service credential fields now saved from form to JSON column
     (collect cred_* fields, strip prefix, JSON encode on save;
      expand back on load for editing)
H-1: Joomla 5 event ArrayAccess pattern for service plugin collection
     (reads from Event indices instead of broken by-reference)
H-4: ServiceTable::check() with alias generation, required validation
H-9: WebhookService credential keys match form XML field names,
     Bearer/Basic auth headers implemented correctly
M-4: XSS fix — escape $extraClass in ServiceIconHelper::renderIcon()
M-5: Article history HTML injection via setFieldAttribute() instead
     of double-escaped XML description attribute

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 00:28:36 -05:00
gitea-actions[bot] 353c037907 chore: update development channel 01.00.06-dev-dev [skip ci] 2026-05-29 05:13:42 +00:00
gitea-actions[bot] fcb332ea00 chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-29 05:13:40 +00:00
Jonathan Miller 20ee39f54b feat: 5 final features — category routing, char counter, service icons, drill-down analytics, article history
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Update Server / Update Server (push) Successful in 9s
1. Category routing rules — new DB table #__mokojoomcross_category_rules
   maps Joomla categories to specific services (whitelist). Integrated
   into CrossPostDispatcher before per-article filters.
2. Character counter — live JS counter in template editor shows
   remaining chars per platform with color coding (green/yellow/red)
3. Service type icons — ServiceIconHelper maps 34 types to Bootstrap
   icons, used in services list, posts list, and dashboard
4. Per-service analytics drill-down — ServiceStats view with stats
   cards, daily trend chart, recent posts, top articles. Dashboard
   service rows are now clickable links.
5. Article editor cross-post history — read-only panel in the
   Cross-Posting fieldset showing last 10 post results with status
   badges, service names, and timestamps

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 00:13:30 -05:00
gitea-actions[bot] 57f34f0fc7 chore: update development channel 01.00.06-dev-dev [skip ci] 2026-05-29 03:56:02 +00:00
gitea-actions[bot] 58a9641b94 chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-29 03:56:01 +00:00
Jonathan Miller 16c068b4b0 feat: getSupportedMediaTypes() on all 36 service plugins
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Update Server / Update Server (push) Successful in 9s
Add media capability reporting to MokoJoomCrossServiceInterface.
Each plugin now returns its supported media types:
- image, video, gif, document (per platform capability)
- Empty array for text-only services (Nostr, Ntfy, ConvertKit)

Enables the dispatcher to skip media attachments for text-only
services and choose appropriate media types per platform.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 22:55:51 -05:00
gitea-actions[bot] de869c2b5d chore: update development channel 01.00.06-dev-dev [skip ci] 2026-05-29 03:52:43 +00:00
gitea-actions[bot] b8cd0253e1 chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-29 03:52:42 +00:00
Jonathan Miller c3899b65d3 feat: bulk re-queue, purge posted, CSV export, package manifest update
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Update Server / Update Server (push) Successful in 8s
- PostsController: retryFailed(), purgePosted(), exportCsv() tasks
- Posts HtmlView: Retry Failed, Purge Posted, Export CSV toolbar buttons
- pkg_mokojoomcross.xml: added new sub-extension entries

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 22:52:30 -05:00
gitea-actions[bot] 7777ffca32 chore: update development channel 01.00.06-dev-dev [skip ci] 2026-05-29 03:48:32 +00:00
gitea-actions[bot] 90340dd499 chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-29 03:48:31 +00:00
Jonathan Miller 7747fef50e refactor: split content-type dispatch into pluggable source plugins
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Update Server / Update Server (push) Successful in 8s
Extract dispatch logic from monolithic system plugin into a shared
CrossPostDispatcher helper. Each content type now has its own plugin:

- plg_content_mokojoomcross — articles (onContentAfterSave/ChangeState)
- plg_system_mokojoomcross_events — MokoJoomCalendar events
- plg_system_mokojoomcross_gallery — MokoJoomGallery galleries/images
- plg_mokojoomcross_mokojoomcalendar — calendar service enrichment
- plg_mokojoomcross_mokojoomgallery — gallery service enrichment

System plugin stripped to page-load queue processing only.

Also fixes: onContentBeforeDisplay Joomla 5/6 BeforeDisplayEvent
compatibility (was crashing with wrong argument type).

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 22:48:18 -05:00
gitea-actions[bot] 3586ce7661 chore: update development channel 01.00.06-dev-dev [skip ci] 2026-05-29 03:33:54 +00:00
gitea-actions[bot] 83dc2fa013 chore(version): auto-bump 01.00.06-dev-dev [skip ci] 2026-05-29 03:33:52 +00:00
Jonathan Miller 9544f4f0bb feat: 7 medium-effort enhancements
Universal: Auto Version Bump / Version Bump (push) Successful in 4s
Update Server / Update Server (push) Successful in 10s
1. Image attachment pipeline — article intro image passed to publish()
   via $media array (system plugin + QueueProcessor)
2. Custom fields as placeholders — {field:xxx} resolves Joomla custom
   fields in templates
3. Lifecycle events — onMokoJoomCrossBeforePost (cancellable),
   AfterPost, PostFailed for third-party hooks
4. Token auto-refresh — OAuthHelper::refreshTokenIfNeeded() checks
   token_expires and refreshes via refresh_token before each publish
5. DB lock race fix — MySQL GET_LOCK() replaces read-then-write pattern
6. WordPress canonical URL — appends source link to cross-posted content
7. REST API dispatch — POST /api/v1/mokojoomcross/dispatch triggers
   cross-posting programmatically with article_id + optional service_ids

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 22:33:42 -05:00
89 changed files with 3602 additions and 473 deletions
+34
View File
@@ -9,6 +9,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Fixed
- **C-1 OauthController**: Added CSRF nonce validation to OAuth callback — session-based nonce is generated during `authorize()`, embedded in the state parameter, and verified in `callback()` to prevent CSRF attacks
- **C-2 DispatchController**: Added POST method enforcement — rejects non-POST requests with 405 status
- **C-5 ServiceModel**: Credential form fields (`cred_*`) are now collected into the `credentials` JSON column on save, and expanded back into individual fields on load — previously these fields were silently discarded
- **H-1 Event pattern**: Fixed Joomla 5 SubscriberInterface incompatibility where `onMokoJoomCrossGetServices` by-reference pattern silently lost all service plugins — dispatchers now read plugin instances from Event ArrayAccess indices after dispatch
- **H-4 ServiceTable**: Added `check()` method with alias generation, required field validation (title, service_type), timestamp management, and JSON defaults for credentials/params
- **H-9 WebhookService**: Fixed credential key mismatch — `publish()` and `validateCredentials()` now use keys matching the service.xml form fields (`url`, `method`, `auth_type`, `bearer_token`, `basic_username`, `basic_password`, `content_type`) and properly apply Bearer/Basic auth headers
- **M-4 ServiceIconHelper**: Escaped `$extraClass` parameter in `renderIcon()` with `htmlspecialchars()` to prevent XSS
- **M-5 Content plugin**: Fixed double-escaped HTML in cross-post history panel — uses `setFieldAttribute()` to inject history HTML into the note field description after XML load, avoiding XML attribute encoding
### Added
- **ServiceIconHelper**: Centralised icon mapping for all 34 service types — replaces per-template icon arrays with `ServiceIconHelper::getIcon()` / `::renderIcon()`
- **Service Stats drill-down**: New `servicestats` view with per-service analytics — post counts, success rate, daily trend chart, recent posts table, and top articles list
- **Dashboard service links**: Service breakdown table rows now link to the per-service stats view with service type icons
- **Posts list icons**: Service type column in the posts list now shows the service icon
- **Category routing rules**: New `#__mokojoomcross_category_rules` table to whitelist services per Joomla category — if rules exist for a category, only those services receive posts; no rules = all services (backward compatible)
- **CrossPostDispatcher**: Category rule filtering integrated before per-article service filter in the dispatch loop
- **Template editor**: Live character counter below template body textarea with platform-aware limits (green/yellow/red badges)
- **Template editor**: Added `{tags}`, `{hashtags}`, and `{field:xxx}` rows to the placeholder reference table
- **Content plugin**: Cross-post history panel in article editor showing last 10 posts with status badges, service names, timestamps, and error messages
- **Config**: New "Category Rules" fieldset with explanatory note about the feature
### 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 +82,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 -1
View File
@@ -1,6 +1,6 @@
# MokoJoomCross
<!-- VERSION: 01.00.18 -->
<!-- VERSION: 01.00.24 -->
Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 5/6.
@@ -134,4 +134,13 @@
showon="queue_processing:pageload,both"
/>
</fieldset>
<fieldset name="category_rules" label="COM_MOKOJOOMCROSS_CONFIG_CATEGORY_RULES">
<field
name="category_rules_note"
type="note"
label="COM_MOKOJOOMCROSS_CONFIG_CATEGORY_RULES_NOTE"
description="COM_MOKOJOOMCROSS_CONFIG_CATEGORY_RULES_NOTE_DESC"
/>
</fieldset>
</config>
@@ -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"
@@ -486,6 +491,23 @@ COM_MOKOJOOMCROSS_PERIOD_ALL_TIME="All time"
; Hashtag Placeholders
COM_MOKOJOOMCROSS_PLACEHOLDER_TAGS="Article tags (comma-separated)"
COM_MOKOJOOMCROSS_PLACEHOLDER_HASHTAGS="Article tags as hashtags (#Tag1 #Tag2)"
COM_MOKOJOOMCROSS_PLACEHOLDER_CUSTOM_FIELD="Custom field value (replace xxx with field name)"
; CSV Export
COM_MOKOJOOMCROSS_EXPORT_CSV="Export CSV"
; Service Stats (drill-down)
COM_MOKOJOOMCROSS_SERVICESTATS_RECENT_POSTS="Recent Posts"
COM_MOKOJOOMCROSS_SERVICESTATS_NO_POSTS="No posts for this service yet."
COM_MOKOJOOMCROSS_SERVICESTATS_TOP_ARTICLES="Top Articles for This Service"
; 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."
; Category Rules
COM_MOKOJOOMCROSS_CONFIG_CATEGORY_RULES="Category Rules"
COM_MOKOJOOMCROSS_CONFIG_CATEGORY_RULES_NOTE="Category Routing"
COM_MOKOJOOMCROSS_CONFIG_CATEGORY_RULES_NOTE_DESC="Category routing rules let you map Joomla categories to specific cross-post services. When rules exist for a category, only those services receive posts. When no rules exist, all services are used (default behaviour). Rules are managed in the database table #__mokojoomcross_category_rules. A full admin UI will be added in a future release."
@@ -93,3 +93,13 @@ INSERT INTO `#__mokojoomcross_templates` (`service_type`, `title`, `template_bod
('ntfy', 'Ntfy Default', '{title}: {introtext}', 1, 18, NOW()),
('reddit', 'Reddit Default', '{title}', 1, 19, NOW()),
('pinterest', 'Pinterest Default', '{title} - {introtext}', 1, 20, NOW());
CREATE TABLE IF NOT EXISTS `#__mokojoomcross_category_rules` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`category_id` int(10) unsigned NOT NULL,
`service_id` int(10) unsigned NOT NULL,
`published` tinyint(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_category_service` (`category_id`, `service_id`),
KEY `idx_category` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,13 @@
-- MokoJoomCross 01.01.00 — Category routing rules
-- Copyright (C) 2026 Moko Consulting. All rights reserved.
-- SPDX-License-Identifier: GPL-3.0-or-later
CREATE TABLE IF NOT EXISTS `#__mokojoomcross_category_rules` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`category_id` int(10) unsigned NOT NULL,
`service_id` int(10) unsigned NOT NULL,
`published` tinyint(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_category_service` (`category_id`, `service_id`),
KEY `idx_category` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,351 @@
<?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.
*
* Authentication is handled by Joomla's API application (token or session).
* The webservices plugin routes POST requests here via the API router.
*/
class DispatchController extends BaseController
{
/**
* Dispatch cross-posts for an article to one or more services.
*
* @return void
*/
public function dispatch(): void
{
$app = $this->app;
// Enforce POST method — this is a state-changing action endpoint
if (strtoupper($this->input->getMethod()) !== 'POST') {
$this->sendJsonResponse(['error' => 'Method not allowed. Use POST.'], 405);
return;
}
// 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.
// In Joomla 5+ with SubscriberInterface, plugins receive the Event object
// as their first argument. When they do $services[] = $this, they append to
// the Event via ArrayAccess at numeric indices starting at 1.
PluginHelper::importPlugin('mokojoomcross');
$servicePlugins = [];
$event = new \Joomla\Event\Event('onMokoJoomCrossGetServices', [$servicePlugins]);
try {
$app->getDispatcher()->dispatch('onMokoJoomCrossGetServices', $event);
} catch (\Throwable $e) {
// Dispatcher may not be available
}
// Read plugins back from the Event's ArrayAccess indices
$idx = 1;
while (isset($event[$idx])) {
$servicePlugins[] = $event[$idx];
$idx++;
}
$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();
}
}
@@ -13,6 +13,7 @@ namespace Joomla\Component\MokoJoomCross\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Plugin\PluginHelper;
@@ -84,7 +85,11 @@ class OauthController extends BaseController
return;
}
$url = OAuthHelper::getAuthorizeUrl($service->service_type, $serviceId, $clientId);
// Generate CSRF nonce and store in session
$nonce = bin2hex(random_bytes(16));
Factory::getApplication()->getSession()->set('mokojoomcross.oauth_nonce', $nonce);
$url = OAuthHelper::getAuthorizeUrl($service->service_type, $serviceId, $clientId, $nonce);
if (!$url) {
$this->setRedirect(
@@ -133,6 +138,7 @@ class OauthController extends BaseController
$stateData = json_decode(base64_decode($state), true);
$serviceId = (int) ($stateData['service_id'] ?? 0);
$serviceType = $stateData['type'] ?? '';
$stateNonce = $stateData['nonce'] ?? '';
if (!$serviceId || !$serviceType) {
$this->setRedirect(
@@ -144,6 +150,21 @@ class OauthController extends BaseController
return;
}
// CSRF nonce validation — compare state nonce against session
$session = Factory::getApplication()->getSession();
$sessionNonce = $session->get('mokojoomcross.oauth_nonce', '');
$session->clear('mokojoomcross.oauth_nonce');
if (empty($stateNonce) || !hash_equals($sessionNonce, $stateNonce)) {
$this->setRedirect(
Route::_('index.php?option=com_mokojoomcross&view=services', false),
Text::_('COM_MOKOJOOMCROSS_OAUTH_INVALID_STATE'),
'error'
);
return;
}
// Get client credentials from plugin params
PluginHelper::importPlugin('mokojoomcross');
$pluginParams = PluginHelper::getPlugin('mokojoomcross', $serviceType);
@@ -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,451 @@
<?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.
// In Joomla 5+ with SubscriberInterface, plugins receive the Event object
// as their first argument. When they do $services[] = $this, they append to
// the Event via ArrayAccess at numeric indices starting at 1.
$servicePlugins = [];
$event = new \Joomla\Event\Event('onMokoJoomCrossGetServices', [$servicePlugins]);
try {
Factory::getApplication()->getDispatcher()->dispatch('onMokoJoomCrossGetServices', $event);
} catch (\Throwable $e) {
// Dispatcher may not be available in all contexts
}
// Read plugins back from the Event's ArrayAccess indices
$idx = 1;
while (isset($event[$idx])) {
$servicePlugins[] = $event[$idx];
$idx++;
}
// 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
}
// Category routing rules — whitelist services by category
$categoryServiceIds = null;
if (!empty($article->catid)) {
$query = $db->getQuery(true)
->select('service_id')
->from($db->quoteName('#__mokojoomcross_category_rules'))
->where($db->quoteName('category_id') . ' = ' . (int) $article->catid)
->where($db->quoteName('published') . ' = 1');
$db->setQuery($query);
$ruleIds = $db->loadColumn();
if (!empty($ruleIds)) {
$categoryServiceIds = array_map('intval', $ruleIds);
}
}
// Determine service type filter from content type property
$serviceTypeFilter = $article->_content_type ?? null;
foreach ($services as $service) {
// Category routing filter — if rules exist, only post to whitelisted services
if ($categoryServiceIds !== null && !in_array((int) $service->id, $categoryServiceIds, true)) {
continue;
}
// 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);
}
}
@@ -61,7 +61,7 @@ class OAuthHelper
*
* @return string|null Authorization URL or null if not supported
*/
public static function getAuthorizeUrl(string $serviceType, int $serviceId, string $clientId): ?string
public static function getAuthorizeUrl(string $serviceType, int $serviceId, string $clientId, string $nonce = ''): ?string
{
$config = self::OAUTH_CONFIGS[$serviceType] ?? null;
@@ -70,7 +70,13 @@ class OAuthHelper
}
$redirectUri = self::getCallbackUrl();
$state = base64_encode(json_encode(['service_id' => $serviceId, 'type' => $serviceType]));
$statePayload = ['service_id' => $serviceId, 'type' => $serviceType];
if (!empty($nonce)) {
$statePayload['nonce'] = $nonce;
}
$state = base64_encode(json_encode($statePayload));
$params = [
'client_id' => $clientId,
@@ -192,6 +198,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']++;
}
}
@@ -246,19 +342,9 @@ class QueueProcessor
return $result;
}
// Load the system plugin for template rendering
PluginHelper::importPlugin('system');
$systemPlugin = null;
try {
$plugins = [];
Factory::getApplication()->getDispatcher()->dispatch(
'onMokoJoomCrossGetServices',
new \Joomla\Event\Event('onMokoJoomCrossGetServices', [&$plugins])
);
} catch (\Throwable $e) {
// Not critical for queuing
}
// Import service plugins (not used for direct dispatch here, but ensures
// they are loaded in case any lifecycle events depend on them)
PluginHelper::importPlugin('mokojoomcross');
foreach ($articles as $article) {
if ($result['queued'] >= $maxPerRun) {
@@ -454,7 +540,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;
}
/**
@@ -504,17 +677,29 @@ class QueueProcessor
{
PluginHelper::importPlugin('mokojoomcross');
// In Joomla 5+ with SubscriberInterface, plugins receive the Event object
// as their first argument. When they do $services[] = $this, they append to
// the Event via ArrayAccess at numeric indices starting at 1.
$servicePlugins = [];
$event = new \Joomla\Event\Event('onMokoJoomCrossGetServices', [$servicePlugins]);
try {
Factory::getApplication()->getDispatcher()->dispatch(
'onMokoJoomCrossGetServices',
new \Joomla\Event\Event('onMokoJoomCrossGetServices', [&$servicePlugins])
$event
);
} catch (\Throwable $e) {
// Dispatcher may not be available in all contexts
}
// Read plugins back from the Event's ArrayAccess indices
$idx = 1;
while (isset($event[$idx])) {
$servicePlugins[] = $event[$idx];
$idx++;
}
$map = [];
foreach ($servicePlugins as $plugin) {
@@ -548,68 +733,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();
}
@@ -0,0 +1,96 @@
<?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;
/**
* Static helper that maps service types to Joomla Bootstrap icons.
*/
class ServiceIconHelper
{
/**
* Map of service type identifiers to icon CSS classes.
*
* @var array<string, string>
*/
private const ICONS = [
// Social
'facebook' => 'icon-facebook',
'twitter' => 'icon-twitter',
'linkedin' => 'icon-linkedin',
'mastodon' => 'icon-globe',
'bluesky' => 'icon-cloud',
'threads' => 'icon-comments',
'pinterest' => 'icon-thumbtack',
'reddit' => 'icon-comments-alt',
'tumblr' => 'icon-pencil-alt',
'tiktok' => 'icon-play-circle',
'nostr' => 'icon-key',
'activitypub' => 'icon-network-wired',
// Chat
'telegram' => 'icon-paper-plane',
'discord' => 'icon-headset',
'slack' => 'icon-hashtag',
'teams' => 'icon-users',
'googlechat' => 'icon-comment',
'whatsapp' => 'icon-mobile',
'matrix' => 'icon-th',
'ntfy' => 'icon-bell',
// Email
'mailchimp' => 'icon-envelope',
'sendgrid' => 'icon-envelope-open',
'brevo' => 'icon-at',
'convertkit' => 'icon-mail-bulk',
'constantcontact' => 'icon-address-book',
// Publishing
'medium' => 'icon-book',
'wordpress' => 'icon-blog',
'devto' => 'icon-code',
'ghost' => 'icon-ghost',
'hashnode' => 'icon-newspaper',
'blogger' => 'icon-rss',
// Business
'googlebusiness' => 'icon-store',
// Universal
'webhook' => 'icon-plug',
'rssfeed' => 'icon-rss-square',
];
/**
* Get the icon CSS class for a service type.
*
* @param string $serviceType The service type identifier
*
* @return string Icon CSS class
*/
public static function getIcon(string $serviceType): string
{
return self::ICONS[$serviceType] ?? 'icon-share-alt';
}
/**
* Render an icon span element for a service type.
*
* @param string $serviceType The service type identifier
* @param string $extraClass Additional CSS classes to append
*
* @return string HTML span element
*/
public static function renderIcon(string $serviceType, string $extraClass = ''): string
{
$icon = self::getIcon($serviceType);
$class = trim($icon . ' ' . htmlspecialchars($extraClass, ENT_QUOTES, 'UTF-8'));
return '<span class="' . $class . '" aria-hidden="true"></span>';
}
}
@@ -107,6 +107,7 @@ class DashboardModel extends BaseDatabaseModel
$query = $db->getQuery(true)
->select([
$db->quoteName('s.id', 'service_id'),
$db->quoteName('s.service_type'),
$db->quoteName('s.title', 'service_title'),
'SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS posted',
@@ -13,6 +13,8 @@ namespace Joomla\Component\MokoJoomCross\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Filter\OutputFilter;
use Joomla\CMS\MVC\Model\AdminModel;
class ServiceModel extends AdminModel
@@ -43,12 +45,77 @@ class ServiceModel extends AdminModel
/**
* Method to get the data that should be injected in the form.
*
* Expands the JSON credentials column back into individual cred_* form fields
* so they are populated when editing an existing service.
*
* @return mixed The data for the form
*/
protected function loadFormData()
{
$data = $this->getItem();
if ($data && !empty($data->credentials)) {
$credentials = json_decode($data->credentials, true) ?: [];
$serviceType = $data->service_type ?? '';
foreach ($credentials as $key => $value) {
// Map credential keys back to form field names.
// The mode field has no service type prefix.
if ($key === 'mode') {
$data->cred_mode = $value;
} else {
$data->{'cred_' . $serviceType . '_' . $key} = $value;
}
}
}
return $data;
}
/**
* Override save to collect cred_* form fields into the credentials JSON column.
*
* The service form has individual fields (cred_twitter_api_key, cred_facebook_page_id, etc.)
* but the database stores them as a single JSON blob in the `credentials` column.
*
* @param array $data The form data
*
* @return boolean True on success
*/
public function save($data)
{
$serviceType = $data['service_type'] ?? '';
$credentials = [];
$credPrefix = 'cred_';
// Collect all cred_* fields into the credentials array
foreach ($data as $key => $value) {
if (strpos($key, $credPrefix) !== 0) {
continue;
}
$credKey = substr($key, strlen($credPrefix));
// The mode field is shared across service types (no service_type prefix)
if ($credKey === 'mode') {
$credentials['mode'] = $value;
} elseif ($serviceType && strpos($credKey, $serviceType . '_') === 0) {
// Strip the service_type prefix: cred_twitter_api_key -> api_key
$strippedKey = substr($credKey, strlen($serviceType) + 1);
$credentials[$strippedKey] = $value;
}
}
// Store the credentials JSON
$data['credentials'] = !empty($credentials) ? json_encode($credentials) : '{}';
// Remove individual cred_* fields so they don't cause column-not-found errors
foreach (array_keys($data) as $key) {
if (strpos($key, $credPrefix) === 0) {
unset($data[$key]);
}
}
return parent::save($data);
}
}
@@ -0,0 +1,185 @@
<?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\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
/**
* Per-service analytics drill-down model.
*/
class ServiceStatsModel extends BaseDatabaseModel
{
/**
* Get the service ID from the request.
*
* @return int
*/
public function getServiceId(): int
{
return Factory::getApplication()->input->getInt('id', 0);
}
/**
* Load a single service record by ID.
*
* @param int $id Service ID
*
* @return object|null
*/
public function getService(int $id = 0): ?object
{
if ($id === 0) {
$id = $this->getServiceId();
}
if ($id === 0) {
return null;
}
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokojoomcross_services'))
->where($db->quoteName('id') . ' = ' . (int) $id);
$db->setQuery($query);
return $db->loadObject() ?: null;
}
/**
* Get post status counts for a specific service.
*
* @param int $serviceId Service ID
*
* @return object Object with total, posted, failed, queued properties
*/
public function getPostStats(int $serviceId): object
{
$db = $this->getDatabase();
$stats = new \stdClass();
foreach (['queued', 'posted', 'failed'] as $status) {
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokojoomcross_posts'))
->where($db->quoteName('service_id') . ' = ' . (int) $serviceId)
->where($db->quoteName('status') . ' = ' . $db->quote($status));
$db->setQuery($query);
$stats->{$status} = (int) $db->loadResult();
}
$stats->total = $stats->queued + $stats->posted + $stats->failed;
return $stats;
}
/**
* Get daily post trend for a specific service.
*
* @param int $serviceId Service ID
* @param int $days Number of days to look back
*
* @return array [['day' => '2026-05-28', 'posted' => N, 'failed' => N], ...]
*/
public function getDailyTrend(int $serviceId, int $days = 30): array
{
$db = $this->getDatabase();
$cutoff = Factory::getDate('now - ' . $days . ' days')->format('Y-m-d');
$query = $db->getQuery(true)
->select([
'DATE(' . $db->quoteName('created') . ') AS day',
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS posted',
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('failed') . ' THEN 1 ELSE 0 END) AS failed',
'COUNT(*) AS total',
])
->from($db->quoteName('#__mokojoomcross_posts'))
->where($db->quoteName('service_id') . ' = ' . (int) $serviceId)
->where('DATE(' . $db->quoteName('created') . ') >= ' . $db->quote($cutoff))
->group('DATE(' . $db->quoteName('created') . ')')
->order('day ASC');
$db->setQuery($query);
return $db->loadAssocList() ?: [];
}
/**
* Get recent posts for a specific service with article titles.
*
* @param int $serviceId Service ID
* @param int $limit Number of posts to return
*
* @return array
*/
public function getRecentPosts(int $serviceId, int $limit = 20): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('p.id'),
$db->quoteName('p.status'),
$db->quoteName('p.posted_at'),
$db->quoteName('p.created'),
$db->quoteName('p.error_message'),
$db->quoteName('p.retry_count'),
$db->quoteName('c.title', 'article_title'),
])
->from($db->quoteName('#__mokojoomcross_posts', 'p'))
->join('LEFT', $db->quoteName('#__content', 'c')
. ' ON ' . $db->quoteName('c.id') . ' = ' . $db->quoteName('p.article_id'))
->where($db->quoteName('p.service_id') . ' = ' . (int) $serviceId)
->order($db->quoteName('p.created') . ' DESC');
$db->setQuery($query, 0, $limit);
return $db->loadAssocList() ?: [];
}
/**
* Get the most cross-posted articles for a specific service.
*
* @param int $serviceId Service ID
* @param int $limit Number of articles to return
*
* @return array
*/
public function getTopArticles(int $serviceId, int $limit = 10): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([
$db->quoteName('c.id'),
$db->quoteName('c.title'),
'COUNT(*) AS post_count',
'SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success_count',
])
->from($db->quoteName('#__mokojoomcross_posts', 'p'))
->join('INNER', $db->quoteName('#__content', 'c')
. ' ON ' . $db->quoteName('c.id') . ' = ' . $db->quoteName('p.article_id'))
->where($db->quoteName('p.service_id') . ' = ' . (int) $serviceId)
->group($db->quoteName(['c.id', 'c.title']))
->order('post_count DESC');
$db->setQuery($query, 0, $limit);
return $db->loadAssocList() ?: [];
}
}
@@ -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;
}
@@ -13,6 +13,9 @@ namespace Joomla\Component\MokoJoomCross\Administrator\Table;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Filter\OutputFilter;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;
@@ -22,4 +25,67 @@ class ServiceTable extends Table
{
parent::__construct('#__mokojoomcross_services', 'id', $db);
}
/**
* Validate the record before storing.
*
* Generates alias from title if empty, validates required fields,
* sets created/modified timestamps.
*
* @return boolean True if the record is valid
*/
public function check(): bool
{
// Title is required
if (empty($this->title)) {
$this->setError(Text::_('COM_MOKOJOOMCROSS_ERROR_TITLE_REQUIRED'));
return false;
}
// Service type is required
if (empty($this->service_type)) {
$this->setError(Text::_('COM_MOKOJOOMCROSS_ERROR_SERVICE_TYPE_REQUIRED'));
return false;
}
// Generate alias from title if empty
if (empty($this->alias)) {
$this->alias = $this->title;
}
$this->alias = OutputFilter::stringURLSafe($this->alias);
// Make sure alias is unique
if (empty($this->alias)) {
$this->alias = Factory::getDate()->format('Y-m-d-H-i-s');
}
// Set timestamps
$now = Factory::getDate()->toSql();
if (empty($this->created)) {
$this->created = $now;
}
$this->modified = $now;
// Set created_by if not set
if (empty($this->created_by)) {
$this->created_by = Factory::getApplication()->getIdentity()->id ?? 0;
}
// Ensure credentials is valid JSON
if (empty($this->credentials)) {
$this->credentials = '{}';
}
// Ensure params is valid JSON
if (empty($this->params)) {
$this->params = '{}';
}
return true;
}
}
@@ -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
@@ -0,0 +1,84 @@
<?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\View\ServiceStats;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Toolbar\Toolbar;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\Component\MokoJoomCross\Administrator\Helper\MokoJoomCrossHelper;
/**
* Per-service analytics drill-down view.
*/
class HtmlView extends BaseHtmlView
{
public $service;
public $postStats;
public $dailyTrend;
public $recentPosts;
public $topArticles;
public $period;
public function display($tpl = null): void
{
/** @var \Joomla\Component\MokoJoomCross\Administrator\Model\ServiceStatsModel $model */
$model = $this->getModel();
$serviceId = $model->getServiceId();
$this->service = $model->getService($serviceId);
if (!$this->service) {
throw new \RuntimeException('Service not found.', 404);
}
$this->period = Factory::getApplication()->input->getInt('period', 30);
$validPeriods = [7, 30, 90, 0];
if (!\in_array($this->period, $validPeriods, true)) {
$this->period = 30;
}
$days = $this->period ?: 365;
$this->postStats = $model->getPostStats($serviceId);
$this->dailyTrend = $model->getDailyTrend($serviceId, $days);
$this->recentPosts = $model->getRecentPosts($serviceId, 20);
$this->topArticles = $model->getTopArticles($serviceId, 10);
$this->addToolbar();
MokoJoomCrossHelper::addSubmenu('servicestats');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(
'MokoJoomCross — ' . $this->escape($this->service->title),
'share-alt'
);
$toolbar = Toolbar::getInstance('toolbar');
$toolbar->appendButton(
'Link',
'home',
'COM_MOKOJOOMCROSS_SUBMENU_DASHBOARD',
Route::_('index.php?option=com_mokojoomcross&view=dashboard', false)
);
}
}
@@ -14,6 +14,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\Component\MokoJoomCross\Administrator\Helper\ServiceIconHelper;
/** @var \Joomla\Component\MokoJoomCross\Administrator\View\Dashboard\HtmlView $this */
$stats = $this->stats;
@@ -177,7 +178,12 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler');
$rateClass = $rate >= 80 ? 'text-success' : ($rate >= 50 ? 'text-warning' : 'text-danger');
?>
<tr>
<td><?php echo htmlspecialchars($row['service_title'] . ' (' . ucfirst($row['service_type']) . ')'); ?></td>
<td>
<?php echo ServiceIconHelper::renderIcon($row['service_type']); ?>
<a href="<?php echo Route::_('index.php?option=com_mokojoomcross&view=servicestats&id=' . $row['service_id']); ?>">
<?php echo htmlspecialchars($row['service_title'] . ' (' . ucfirst($row['service_type']) . ')'); ?>
</a>
</td>
<td class="text-center"><span class="badge bg-success"><?php echo (int) $row['posted']; ?></span></td>
<td class="text-center"><span class="badge bg-danger"><?php echo (int) $row['failed']; ?></span></td>
<td class="text-center"><span class="badge bg-warning text-dark"><?php echo (int) $row['queued']; ?></span></td>
@@ -15,6 +15,7 @@ use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Router\Route;
use Joomla\Component\MokoJoomCross\Administrator\Helper\ServiceIconHelper;
/** @var \Joomla\Component\MokoJoomCross\Administrator\View\Posts\HtmlView $this */
@@ -102,7 +103,7 @@ $statusBadges = [
</td>
<td>
<?php echo $this->escape($item->service_title ?? ''); ?>
<br><small class="text-muted"><?php echo $this->escape($item->service_type ?? ''); ?></small>
<br><small class="text-muted"><?php echo ServiceIconHelper::renderIcon($item->service_type ?? ''); ?> <?php echo $this->escape($item->service_type ?? ''); ?></small>
</td>
<td class="d-none d-md-table-cell">
<small><?php echo $this->escape(mb_substr($item->message ?? '', 0, 100)); ?></small>
@@ -15,6 +15,7 @@ use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Router\Route;
use Joomla\Component\MokoJoomCross\Administrator\Helper\ServiceIconHelper;
/** @var \Joomla\Component\MokoJoomCross\Administrator\View\Services\HtmlView $this */
@@ -23,17 +24,6 @@ HTMLHelper::_('behavior.multiselect');
$listOrder = $this->escape($this->state->get('list.ordering'));
$listDirn = $this->escape($this->state->get('list.direction'));
$serviceIcons = [
'facebook' => 'icon-facebook',
'twitter' => 'icon-twitter',
'linkedin' => 'icon-linkedin',
'mastodon' => 'icon-globe',
'bluesky' => 'icon-cloud',
'mailchimp' => 'icon-envelope',
'telegram' => 'icon-comment',
'discord' => 'icon-comments',
'slack' => 'icon-comments-2',
];
?>
<form action="<?php echo Route::_('index.php?option=com_mokojoomcross&view=services'); ?>" method="post" name="adminForm" id="adminForm">
<div class="row">
@@ -75,7 +65,6 @@ $serviceIcons = [
<?php foreach ($this->items as $i => $item) :
$credentials = json_decode($item->credentials ?: '{}', true) ?: [];
$mode = $credentials['mode'] ?? 'custom';
$icon = $serviceIcons[$item->service_type] ?? 'icon-cog';
?>
<tr class="row<?php echo $i % 2; ?>">
<td class="text-center">
@@ -90,7 +79,7 @@ $serviceIcons = [
</a>
</td>
<td>
<span class="<?php echo $icon; ?>" aria-hidden="true"></span>
<?php echo ServiceIconHelper::renderIcon($item->service_type); ?>
<?php echo $this->escape(ucfirst($item->service_type)); ?>
</td>
<td class="text-center d-none d-md-table-cell">
@@ -0,0 +1,219 @@
<?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
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\Component\MokoJoomCross\Administrator\Helper\ServiceIconHelper;
/** @var \Joomla\Component\MokoJoomCross\Administrator\View\ServiceStats\HtmlView $this */
$service = $this->service;
$stats = $this->postStats;
$rate = $stats->total > 0 ? round(($stats->posted / $stats->total) * 100) : 0;
$rateClass = $rate >= 80 ? 'text-success' : ($rate >= 50 ? 'text-warning' : 'text-danger');
$statusBadges = [
'queued' => 'bg-warning text-dark',
'posting' => 'bg-info',
'posted' => 'bg-success',
'failed' => 'bg-danger',
'scheduled' => 'bg-secondary',
];
?>
<!-- Service Header -->
<div class="d-flex align-items-center mb-4">
<?php echo ServiceIconHelper::renderIcon($service->service_type, 'fs-3 me-2'); ?>
<h2 class="mb-0"><?php echo $this->escape($service->title); ?></h2>
<span class="badge bg-secondary ms-2"><?php echo $this->escape(ucfirst($service->service_type)); ?></span>
</div>
<!-- Stats Cards -->
<div class="row">
<div class="col-sm-6 col-md-3">
<div class="card text-center mb-3">
<div class="card-body">
<h5 class="card-title"><?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_TOTAL_POSTS'); ?></h5>
<p class="display-4"><?php echo $stats->total; ?></p>
</div>
</div>
</div>
<div class="col-sm-6 col-md-3">
<div class="card text-center mb-3">
<div class="card-body">
<h5 class="card-title"><?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_POSTED'); ?></h5>
<p class="display-4 text-success"><?php echo $stats->posted; ?></p>
</div>
</div>
</div>
<div class="col-sm-6 col-md-3">
<div class="card text-center mb-3">
<div class="card-body">
<h5 class="card-title"><?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_FAILED'); ?></h5>
<p class="display-4 text-danger"><?php echo $stats->failed; ?></p>
</div>
</div>
</div>
<div class="col-sm-6 col-md-3">
<div class="card text-center mb-3">
<div class="card-body">
<h5 class="card-title"><?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_SUCCESS_RATE'); ?></h5>
<p class="display-4 <?php echo $rateClass; ?>"><?php echo $rate; ?>%</p>
</div>
</div>
</div>
</div>
<!-- Daily Trend Chart -->
<?php if (!empty($this->dailyTrend)) : ?>
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_TREND_CHART'); ?></h5>
<form method="get" class="d-inline">
<input type="hidden" name="option" value="com_mokojoomcross" />
<input type="hidden" name="view" value="servicestats" />
<input type="hidden" name="id" value="<?php echo (int) $service->id; ?>" />
<select name="period" class="form-select form-select-sm" style="width: auto; display: inline-block;" onchange="this.form.submit();">
<option value="7" <?php echo $this->period == 7 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOJOOMCROSS_PERIOD_7_DAYS'); ?></option>
<option value="30" <?php echo $this->period == 30 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOJOOMCROSS_PERIOD_30_DAYS'); ?></option>
<option value="90" <?php echo $this->period == 90 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOJOOMCROSS_PERIOD_90_DAYS'); ?></option>
<option value="0" <?php echo $this->period == 0 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOJOOMCROSS_PERIOD_ALL_TIME'); ?></option>
</select>
</form>
</div>
<div class="card-body">
<canvas id="serviceStatsChart" height="80"></canvas>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js" integrity="sha384-UPIssOjNMqMfON6mDKHvO4sOY4hhxN1ymYcfl2MrDz69idMU/L3MNFlyJGlIRjQH" crossorigin="anonymous"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var trendData = <?php echo json_encode($this->dailyTrend); ?>;
var labels = trendData.map(function(d) { return d.day; });
var posted = trendData.map(function(d) { return parseInt(d.posted, 10); });
var failed = trendData.map(function(d) { return parseInt(d.failed, 10); });
new Chart(document.getElementById('serviceStatsChart'), {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '<?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_POSTED', true); ?>',
data: posted,
borderColor: '#198754',
backgroundColor: 'rgba(25, 135, 84, 0.1)',
fill: true,
tension: 0.3
},
{
label: '<?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_FAILED', true); ?>',
data: failed,
borderColor: '#dc3545',
backgroundColor: 'rgba(220, 53, 69, 0.1)',
fill: true,
tension: 0.3
}
]
},
options: {
responsive: true,
scales: {
y: { beginAtZero: true, ticks: { stepSize: 1 } }
},
plugins: {
legend: { position: 'bottom' }
}
}
});
});
</script>
<?php endif; ?>
<!-- Recent Posts -->
<div class="card mb-3">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOJOOMCROSS_SERVICESTATS_RECENT_POSTS'); ?></h5>
</div>
<div class="card-body p-0">
<?php if (empty($this->recentPosts)) : ?>
<p class="p-3 mb-0 text-muted"><?php echo Text::_('COM_MOKOJOOMCROSS_SERVICESTATS_NO_POSTS'); ?></p>
<?php else : ?>
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKOJOOMCROSS_HEADING_STATUS'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMCROSS_HEADING_ARTICLE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMCROSS_HEADING_POSTED_AT'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMCROSS_POST_ERROR'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($this->recentPosts as $post) :
$badgeClass = $statusBadges[$post['status']] ?? 'bg-secondary';
?>
<tr>
<td>
<span class="badge <?php echo $badgeClass; ?>">
<?php echo $this->escape(ucfirst($post['status'])); ?>
</span>
<?php if ((int) $post['retry_count'] > 0) : ?>
<br><small class="text-muted">Retries: <?php echo (int) $post['retry_count']; ?></small>
<?php endif; ?>
</td>
<td>
<a href="<?php echo Route::_('index.php?option=com_mokojoomcross&task=post.edit&id=' . (int) $post['id']); ?>">
<?php echo $this->escape($post['article_title'] ?? 'Article #' . $post['id']); ?>
</a>
</td>
<td>
<?php echo $post['posted_at'] ? HTMLHelper::_('date', $post['posted_at'], 'Y-m-d H:i') : '—'; ?>
</td>
<td>
<?php if (!empty($post['error_message'])) : ?>
<small class="text-danger"><?php echo $this->escape(mb_substr($post['error_message'], 0, 100)); ?></small>
<?php else : ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<!-- Top Articles -->
<?php if (!empty($this->topArticles)) : ?>
<div class="card mb-3">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOJOOMCROSS_SERVICESTATS_TOP_ARTICLES'); ?></h5>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
<?php foreach ($this->topArticles as $row) : ?>
<div class="list-group-item d-flex justify-content-between align-items-center">
<span><?php echo htmlspecialchars($row['title']); ?></span>
<span>
<span class="badge bg-success"><?php echo (int) $row['success_count']; ?></span>
/
<span class="badge bg-secondary"><?php echo (int) $row['post_count']; ?></span>
</span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
@@ -44,6 +44,9 @@ HTMLHelper::_('behavior.keepalive');
<tr><td><code>{category}</code></td><td><?php echo Text::_('COM_MOKOJOOMCROSS_PLACEHOLDER_CATEGORY'); ?></td></tr>
<tr><td><code>{author}</code></td><td><?php echo Text::_('COM_MOKOJOOMCROSS_PLACEHOLDER_AUTHOR'); ?></td></tr>
<tr><td><code>{date}</code></td><td><?php echo Text::_('COM_MOKOJOOMCROSS_PLACEHOLDER_DATE'); ?></td></tr>
<tr><td><code>{tags}</code></td><td><?php echo Text::_('COM_MOKOJOOMCROSS_PLACEHOLDER_TAGS'); ?></td></tr>
<tr><td><code>{hashtags}</code></td><td><?php echo Text::_('COM_MOKOJOOMCROSS_PLACEHOLDER_HASHTAGS'); ?></td></tr>
<tr><td><code>{field:xxx}</code></td><td><?php echo Text::_('COM_MOKOJOOMCROSS_PLACEHOLDER_CUSTOM_FIELD'); ?></td></tr>
</tbody>
</table>
</div>
@@ -55,3 +58,57 @@ HTMLHelper::_('behavior.keepalive');
<input type="hidden" name="task" value="">
<?php echo HTMLHelper::_('form.token'); ?>
</form>
<script>
document.addEventListener('DOMContentLoaded', function () {
const platformLimits = {
twitter: 280, bluesky: 300, mastodon: 500, threads: 500,
telegram: 4096, discord: 2000, whatsapp: 4096,
linkedin: 3000, googlebusiness: 1500, matrix: 65536,
ntfy: 4096, facebook: 0, medium: 0, wordpress: 0,
ghost: 0, hashnode: 0, blogger: 0, devto: 0,
default: 0
};
const textarea = document.getElementById('jform_template_body');
const serviceSelect = document.getElementById('jform_service_type');
if (!textarea) {
return;
}
// Create counter element
const counter = document.createElement('div');
counter.id = 'mokojoomcross-char-counter';
counter.className = 'small mt-1';
textarea.parentNode.appendChild(counter);
function updateCounter() {
const len = textarea.value.length;
const serviceType = serviceSelect ? serviceSelect.value : 'default';
const limit = platformLimits[serviceType] || 0;
if (limit > 0) {
const ratio = len / limit;
let badgeClass = 'bg-success';
if (ratio > 1) {
badgeClass = 'bg-danger';
} else if (ratio > 0.9) {
badgeClass = 'bg-warning text-dark';
}
counter.innerHTML = '<span class="badge ' + badgeClass + '">Characters: ' + len + ' / ' + limit + '</span>';
} else {
counter.innerHTML = '<span class="badge bg-secondary">Characters: ' + len + ' (no limit)</span>';
}
}
textarea.addEventListener('input', updateCounter);
if (serviceSelect) {
serviceSelect.addEventListener('change', updateCounter);
}
// Initial count
updateCounter();
});
</script>
@@ -10,3 +10,4 @@ PLG_CONTENT_MOKOJOOMCROSS_EVERGREEN="Evergreen Content"
PLG_CONTENT_MOKOJOOMCROSS_EVERGREEN_DESC="Automatically re-share this article on a recurring schedule. Great for high-value content that stays relevant."
PLG_CONTENT_MOKOJOOMCROSS_EVERGREEN_INTERVAL="Re-share Interval (days)"
PLG_CONTENT_MOKOJOOMCROSS_EVERGREEN_INTERVAL_DESC="How many days to wait between automatic re-shares. Default: 30 days."
PLG_CONTENT_MOKOJOOMCROSS_HISTORY="Cross-Post History"
@@ -13,10 +13,14 @@ 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\HTML\HTMLHelper;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Uri\Uri;
use Joomla\Component\MokoJoomCross\Administrator\Helper\CrossPostDispatcher;
use Joomla\Event\SubscriberInterface;
/**
@@ -31,6 +35,8 @@ class MokoJoomCrossContent extends CMSPlugin implements SubscriberInterface
return [
'onContentBeforeDisplay' => 'onContentBeforeDisplay',
'onContentPrepareForm' => 'onContentPrepareForm',
'onContentAfterSave' => 'onContentAfterSave',
'onContentChangeState' => 'onContentChangeState',
];
}
@@ -139,13 +145,82 @@ class MokoJoomCrossContent extends CMSPlugin implements SubscriberInterface
XML;
$form->load($xml);
// Cross-post history panel for existing articles
$articleId = Factory::getApplication()->input->getInt('id', 0);
if ($articleId > 0) {
$query = $db->getQuery(true)
->select('p.status, p.posted_at, p.error_message, s.title AS service_title, s.service_type')
->from($db->quoteName('#__mokojoomcross_posts', 'p'))
->join('LEFT', $db->quoteName('#__mokojoomcross_services', 's') . ' ON s.id = p.service_id')
->where($db->quoteName('p.article_id') . ' = ' . $articleId)
->order('p.created DESC');
$db->setQuery($query, 0, 10);
$history = $db->loadObjectList();
if (!empty($history)) {
$historyHtml = '<div class="mokojoomcross-history">';
foreach ($history as $post) {
$badgeClass = match ($post->status) {
'posted' => 'bg-success',
'failed' => 'bg-danger',
'queued' => 'bg-warning',
default => 'bg-secondary',
};
$historyHtml .= '<div class="mb-1">'
. '<span class="badge ' . $badgeClass . ' me-1">' . ucfirst($post->status) . '</span>'
. '<small>' . htmlspecialchars($post->service_title ?? '') . '</small>';
if ($post->posted_at) {
$historyHtml .= ' <small class="text-muted">' . HTMLHelper::_('date', $post->posted_at, 'Y-m-d H:i') . '</small>';
}
if ($post->status === 'failed' && $post->error_message) {
$historyHtml .= '<br><small class="text-danger">' . htmlspecialchars(mb_substr($post->error_message, 0, 60)) . '</small>';
}
$historyHtml .= '</div>';
}
$historyHtml .= '</div>';
// Add the note field first with an empty description, then set the
// description via setFieldAttribute() to avoid double-escaping.
// Putting raw HTML into an XML attribute via htmlspecialchars() causes
// Joomla's note field renderer to display escaped tags since it outputs
// the description as raw HTML.
$historyXml = '<?xml version="1.0"?>
<form><fields name="attribs"><fieldset name="mokojoomcross">
<field name="mokojoomcross_history" type="note"
label="PLG_CONTENT_MOKOJOOMCROSS_HISTORY"
description="" />
</fieldset></fields></form>';
$form->load($historyXml);
$form->setFieldAttribute('mokojoomcross_history', 'description', $historyHtml, 'attribs');
}
}
}
/**
* 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 +262,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'];
}
}
@@ -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'];
}
}
@@ -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"
@@ -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'];
}
}
@@ -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."
@@ -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'];
}
}
@@ -41,19 +41,20 @@ class WebhookService extends CMSPlugin implements SubscriberInterface, MokoJoomC
public function publish(string $message, array $media, array $credentials, array $params): array
{
$url = $credentials['webhook_url'] ?? $credentials['webhook_url'] ?? '';
// Credential keys match the service.xml form field names (after stripping cred_webhook_ prefix):
// url, method, auth_type, bearer_token, basic_username, basic_password, content_type
$url = $credentials['url'] ?? '';
if (empty($url)) {
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing webhook URL']];
}
$method = $credentials['method'] ?? 'POST';
$headers = $credentials['headers'] ?? [];
$format = $credentials['body_format'] ?? 'json';
$format = $credentials['content_type'] ?? 'json';
$payload = [
'title' => $params['title'] ?? '',
'url' => $params['url'] ?? '',
'url' => $params['_article_url'] ?? $params['url'] ?? '',
'message' => $message,
'image' => $params['image'] ?? '',
'category' => $params['category'] ?? '',
@@ -63,18 +64,23 @@ class WebhookService extends CMSPlugin implements SubscriberInterface, MokoJoomC
$httpHeaders = ['Content-Type: application/json'];
if (is_array($headers)) {
foreach ($headers as $k => $v) {
$httpHeaders[] = "$k: $v";
}
}
$body = ($format === 'form') ? http_build_query($payload) : json_encode($payload);
if ($format === 'form') {
$httpHeaders[0] = 'Content-Type: application/x-www-form-urlencoded';
}
// Apply authentication based on auth_type
$authType = $credentials['auth_type'] ?? 'none';
if ($authType === 'bearer' && !empty($credentials['bearer_token'])) {
$httpHeaders[] = 'Authorization: Bearer ' . $credentials['bearer_token'];
} elseif ($authType === 'basic' && !empty($credentials['basic_username'])) {
$httpHeaders[] = 'Authorization: Basic ' . base64_encode(
$credentials['basic_username'] . ':' . ($credentials['basic_password'] ?? '')
);
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
@@ -99,12 +105,17 @@ class WebhookService extends CMSPlugin implements SubscriberInterface, MokoJoomC
public function validateCredentials(array $credentials): array
{
$key = $credentials['webhook_url'] ?? $credentials['webhook_url'] ?? '';
$url = $credentials['url'] ?? '';
if (empty($key)) {
return ['valid' => false, 'message' => 'Missing credentials', 'account_name' => ''];
if (empty($url)) {
return ['valid' => false, 'message' => 'Missing webhook URL', 'account_name' => ''];
}
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);
}
}
@@ -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,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');
}
}
@@ -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,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)
);
}
}
+2
View File
@@ -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
View File
@@ -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>f8664c24b747a0e2707366c6a8f271ebb68b3d2f1370b113bb88d0bec054012c</sha256>
<tags><tag>dev</tag></tags>
<maintainer>Moko Consulting</maintainer>
<maintainerurl>https://mokoconsulting.tech</maintainerurl>