Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40e540461e | |||
| 8dd6fdd926 | |||
| 353c037907 | |||
| fcb332ea00 | |||
| 20ee39f54b |
@@ -8,6 +8,28 @@ 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MokoJoomCross
|
||||
|
||||
<!-- VERSION: 01.00.22 -->
|
||||
<!-- 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>
|
||||
|
||||
@@ -491,12 +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;
|
||||
@@ -33,6 +33,9 @@ use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInt
|
||||
* }
|
||||
*
|
||||
* 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
|
||||
{
|
||||
@@ -45,6 +48,13 @@ class DispatchController extends BaseController
|
||||
{
|
||||
$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);
|
||||
@@ -104,20 +114,29 @@ class DispatchController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
// Import service plugins and build type-to-plugin map
|
||||
// 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',
|
||||
new \Joomla\Event\Event('onMokoJoomCrossGetServices', [&$servicePlugins])
|
||||
);
|
||||
$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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -56,12 +56,26 @@ class CrossPostDispatcher
|
||||
// Import service plugins so they register with the dispatcher
|
||||
PluginHelper::importPlugin('mokojoomcross');
|
||||
|
||||
// Collect registered service plugin instances
|
||||
// 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 = [];
|
||||
Factory::getApplication()->getDispatcher()->dispatch(
|
||||
'onMokoJoomCrossGetServices',
|
||||
new \Joomla\Event\Event('onMokoJoomCrossGetServices', [&$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 = [];
|
||||
@@ -90,10 +104,31 @@ class CrossPostDispatcher
|
||||
$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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -342,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) {
|
||||
@@ -687,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) {
|
||||
|
||||
@@ -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() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -17,6 +17,7 @@ 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;
|
||||
@@ -144,6 +145,62 @@ 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,10 +105,10 @@ 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'];
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
<downloads>
|
||||
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoJoomCross/releases/download/development/pkg_mokojoomcross-01.00.06-dev-dev.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>e50ba6f989bdae385ddc7a9ce108fe9729feb7aff257b681c3cc9f4bb00c06eb</sha256>
|
||||
<sha256>f8664c24b747a0e2707366c6a8f271ebb68b3d2f1370b113bb88d0bec054012c</sha256>
|
||||
<tags><tag>dev</tag></tags>
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
|
||||
Reference in New Issue
Block a user