feat: 10 quick-win enhancements
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Update Server / Update Server (push) Has been cancelled

1. Test Connection button — AJAX validation on service edit sidebar
2. Bulk re-queue failed + purge posted — toolbar buttons on Posts list
3. Exponential backoff — retry_delay * 2^retry_count replaces fixed delay
4. Queue depth warning — dashboard alert when queued > 50
5. First-publish-only toggle — skip cross-posting on article re-saves
6. Dashboard trend chart — Chart.js line chart for daily posted/failed
7. Hashtag injection — {tags} and {hashtags} template placeholders
8. Posts list filters — service dropdown + search by article/message
9. CSV export — download filtered post history as spreadsheet
10. Dashboard date range — 7d/30d/90d/all filter on analytics

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-05-28 19:13:53 -05:00
parent 5325293db4
commit 3b501719ff
14 changed files with 541 additions and 11 deletions
+5
View File
@@ -45,6 +45,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **Post edit form**: Full CRUD for queue posts — edit message, reschedule, change status, re-queue failed posts
- **Manual post creator**: New button in Post Queue toolbar to create manual cross-posts with article/service selection, custom message, and optional scheduling
- **Scheduled posts**: Calendar picker for scheduling posts to specific date/time; scheduled_at shown in queue list
- **Dashboard trend chart**: Chart.js line chart showing daily posted vs failed counts between stat cards and service breakdown
- **Dashboard date range filter**: Period selector (7/30/90 days, all time) filters service breakdown, top articles, and trend chart
- **Hashtag placeholders**: `{tags}` (comma-separated) and `{hashtags}` (#-prefixed space-separated) template placeholders from article tags
- **Posts service filter**: SQL-driven service dropdown filter in posts list, plus search filter by article title or message content
- **CSV export**: "Export CSV" toolbar button on posts list to download filtered post data as CSV
### Added (original)
+12
View File
@@ -12,6 +12,18 @@
<option value="0">JNO</option>
</field>
<field
name="post_on_first_publish_only"
type="radio"
label="COM_MOKOJOOMCROSS_CONFIG_FIRST_PUBLISH_ONLY"
description="COM_MOKOJOOMCROSS_CONFIG_FIRST_PUBLISH_ONLY_DESC"
default="0"
class="btn-group"
showon="auto_post_on_publish:1">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="retry_max"
type="number"
@@ -20,6 +20,19 @@
<option value="failed">Failed</option>
<option value="scheduled">Scheduled</option>
</field>
<field
name="service_id"
type="sql"
label="COM_MOKOJOOMCROSS_FILTER_SERVICE_TYPE"
onchange="this.form.submit();"
sql_select="id, CONCAT(title, ' (', service_type, ')') AS title"
sql_from="#__mokojoomcross_services"
key_field="id"
value_field="title"
sql_order="ordering ASC">
<option value="">COM_MOKOJOOMCROSS_SELECT_SERVICE</option>
</field>
</fields>
<fields name="list">
@@ -445,3 +445,47 @@ COM_MOKOJOOMCROSS_SETUP_STEP1="Choose a service type from the dropdown"
COM_MOKOJOOMCROSS_SETUP_STEP2="Fill in the connection details that appear"
COM_MOKOJOOMCROSS_SETUP_STEP3="For OAuth services, save first, then click Connect"
COM_MOKOJOOMCROSS_SETUP_STEP4="Set status to Published and save"
; Test Connection
COM_MOKOJOOMCROSS_TEST_CONNECTION_TITLE="Test Connection"
COM_MOKOJOOMCROSS_TEST_CONNECTION_DESC="Verify that your credentials are valid and the service is reachable."
COM_MOKOJOOMCROSS_TEST_CONNECTION_BUTTON="Test Connection"
COM_MOKOJOOMCROSS_TEST_CONNECTION_TESTING="Testing..."
COM_MOKOJOOMCROSS_TEST_CONNECTION_SUCCESS="Connection successful"
COM_MOKOJOOMCROSS_TEST_CONNECTION_FAILED="Connection failed"
COM_MOKOJOOMCROSS_TEST_CONNECTION_ERROR="Could not reach the server. Please try again."
COM_MOKOJOOMCROSS_TEST_CONNECTION_NO_SERVICE="No service specified for test."
COM_MOKOJOOMCROSS_TEST_CONNECTION_NOT_FOUND="Service record not found."
COM_MOKOJOOMCROSS_TEST_CONNECTION_NO_PLUGIN="No service plugin available for type '%s'."
; Bulk Queue Actions
COM_MOKOJOOMCROSS_TOOLBAR_RETRY_FAILED="Retry Failed"
COM_MOKOJOOMCROSS_TOOLBAR_PURGE_POSTED="Purge Posted"
COM_MOKOJOOMCROSS_POSTS_N_RETRIED="%d failed post(s) re-queued for retry."
COM_MOKOJOOMCROSS_POSTS_N_RETRIED_1="1 failed post re-queued for retry."
COM_MOKOJOOMCROSS_POSTS_N_PURGED="%d posted record(s) purged."
COM_MOKOJOOMCROSS_POSTS_N_PURGED_1="1 posted record purged."
; Queue Depth Warning
COM_MOKOJOOMCROSS_DASHBOARD_QUEUE_DEPTH_WARNING_TITLE="Large queue backlog"
COM_MOKOJOOMCROSS_DASHBOARD_QUEUE_DEPTH_WARNING="There are %d posts waiting in the queue. Please verify that the Joomla Task Scheduler is running and the MokoJoomCross scheduled task is enabled in System → Scheduled Tasks."
; First-Publish-Only
COM_MOKOJOOMCROSS_CONFIG_FIRST_PUBLISH_ONLY="First Publish Only"
COM_MOKOJOOMCROSS_CONFIG_FIRST_PUBLISH_ONLY_DESC="When enabled, articles are only cross-posted on their first save as published. Subsequent edits to already-published articles will not trigger new cross-posts."
; Trend Chart
COM_MOKOJOOMCROSS_DASHBOARD_TREND_CHART="Daily Post Trend"
; Date Range Period Filter
COM_MOKOJOOMCROSS_PERIOD_7_DAYS="Last 7 days"
COM_MOKOJOOMCROSS_PERIOD_30_DAYS="Last 30 days"
COM_MOKOJOOMCROSS_PERIOD_90_DAYS="Last 90 days"
COM_MOKOJOOMCROSS_PERIOD_ALL_TIME="All time"
; Hashtag Placeholders
COM_MOKOJOOMCROSS_PLACEHOLDER_TAGS="Article tags (comma-separated)"
COM_MOKOJOOMCROSS_PLACEHOLDER_HASHTAGS="Article tags as hashtags (#Tag1 #Tag2)"
; CSV Export
COM_MOKOJOOMCROSS_EXPORT_CSV="Export CSV"
@@ -13,7 +13,10 @@ namespace Joomla\Component\MokoJoomCross\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\AdminController;
use Joomla\CMS\Router\Route;
class PostsController extends AdminController
{
@@ -21,4 +24,133 @@ class PostsController extends AdminController
{
return parent::getModel($name, $prefix, $config);
}
/**
* Re-queue all failed posts by resetting their status to queued and retry count to 0.
*
* @return void
*/
public function retryFailed(): void
{
$this->checkToken();
$db = Factory::getDbo();
$query = $db->getQuery(true)
->update($db->quoteName('#__mokojoomcross_posts'))
->set($db->quoteName('status') . ' = ' . $db->quote('queued'))
->set($db->quoteName('retry_count') . ' = 0')
->set($db->quoteName('error_message') . ' = ' . $db->quote(''))
->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql()))
->where($db->quoteName('status') . ' = ' . $db->quote('failed'));
$db->setQuery($query);
$db->execute();
$count = $db->getAffectedRows();
$this->setRedirect(
Route::_('index.php?option=com_mokojoomcross&view=posts', false),
Text::plural('COM_MOKOJOOMCROSS_POSTS_N_RETRIED', $count),
'success'
);
}
/**
* Export posts as CSV download.
*
* @return void
*/
public function exportCsv(): void
{
$app = $this->app;
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select([
$db->quoteName('c.title', 'article_title'),
'CONCAT(' . $db->quoteName('s.title') . ', ' . $db->quote(' (') . ', '
. $db->quoteName('s.service_type') . ', ' . $db->quote(')') . ') AS service',
$db->quoteName('a.status'),
$db->quoteName('a.message'),
$db->quoteName('a.posted_at'),
$db->quoteName('a.error_message'),
$db->quoteName('a.platform_post_id'),
$db->quoteName('a.created'),
])
->from($db->quoteName('#__mokojoomcross_posts', 'a'))
->join('LEFT', $db->quoteName('#__content', 'c')
. ' ON ' . $db->quoteName('c.id') . ' = ' . $db->quoteName('a.article_id'))
->join('LEFT', $db->quoteName('#__mokojoomcross_services', 's')
. ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('a.service_id'))
->order($db->quoteName('a.created') . ' DESC');
// Apply current filters
$status = $app->input->get('filter_status', '', 'string');
if (!empty($status)) {
$query->where($db->quoteName('a.status') . ' = ' . $db->quote($status));
}
$serviceId = $app->input->getInt('filter_service_id', 0);
if (!empty($serviceId)) {
$query->where($db->quoteName('a.service_id') . ' = ' . (int) $serviceId);
}
$search = $app->input->get('filter_search', '', 'string');
if (!empty($search)) {
$search = '%' . $db->escape(trim($search), true) . '%';
$query->where('(' . $db->quoteName('c.title') . ' LIKE ' . $db->quote($search)
. ' OR ' . $db->quoteName('a.message') . ' LIKE ' . $db->quote($search) . ')');
}
$db->setQuery($query);
$rows = $db->loadAssocList() ?: [];
$filename = 'mokojoomcross-posts-' . Factory::getDate()->format('Y-m-d') . '.csv';
$app->setHeader('Content-Type', 'text/csv; charset=utf-8');
$app->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
$app->sendHeaders();
$fp = fopen('php://output', 'w');
fputcsv($fp, ['Article', 'Service', 'Status', 'Message', 'Posted At', 'Error', 'Platform Post ID', 'Created']);
foreach ($rows as $row) {
fputcsv($fp, $row);
}
fclose($fp);
$app->close();
}
/**
* Purge (delete) all posts with status 'posted'.
*
* @return void
*/
public function purgePosted(): void
{
$this->checkToken();
$db = Factory::getDbo();
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokojoomcross_posts'))
->where($db->quoteName('status') . ' = ' . $db->quote('posted'));
$db->setQuery($query);
$db->execute();
$count = $db->getAffectedRows();
$this->setRedirect(
Route::_('index.php?option=com_mokojoomcross&view=posts', false),
Text::plural('COM_MOKOJOOMCROSS_POSTS_N_PURGED', $count),
'success'
);
}
}
@@ -13,8 +13,81 @@ namespace Joomla\Component\MokoJoomCross\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\FormController;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\Response\JsonResponse;
use Joomla\Component\MokoJoomCross\Administrator\Service\MokoJoomCrossServiceInterface;
class ServiceController extends FormController
{
/**
* Test connection to a service by validating its credentials.
*
* @return void
*/
public function testConnection(): void
{
$app = $this->app;
$id = (int) $this->input->getInt('id', 0);
try {
if ($id <= 0) {
throw new \RuntimeException(Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_NO_SERVICE'));
}
// Load the service record
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokojoomcross_services'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$service = $db->loadObject();
if (!$service) {
throw new \RuntimeException(Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_NOT_FOUND'));
}
// Get service plugins via dispatcher
PluginHelper::importPlugin('mokojoomcross');
$servicePlugins = [];
$app->getDispatcher()->dispatch(
'onMokoJoomCrossGetServices',
new \Joomla\Event\Event('onMokoJoomCrossGetServices', [&$servicePlugins])
);
// Find the matching plugin
$plugin = null;
foreach ($servicePlugins as $sp) {
if ($sp instanceof MokoJoomCrossServiceInterface && $sp->getServiceType() === $service->service_type) {
$plugin = $sp;
break;
}
}
if (!$plugin) {
throw new \RuntimeException(Text::sprintf('COM_MOKOJOOMCROSS_TEST_CONNECTION_NO_PLUGIN', $service->service_type));
}
// Decode credentials and validate
$credentials = json_decode($service->credentials ?: '{}', true) ?: [];
$result = $plugin->validateCredentials($credentials);
$app->mimeType = 'application/json';
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
echo new JsonResponse($result);
} catch (\Throwable $e) {
$app->mimeType = 'application/json';
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
echo new JsonResponse($e);
}
$app->close();
}
}
@@ -71,9 +71,8 @@ class QueueProcessor
$db->setQuery($query);
$queuedPosts = $db->loadObjectList() ?: [];
// 2. Process failed posts eligible for retry
$retryAfter = Factory::getDate('now - ' . $retryDelay . ' seconds')->toSql();
// 2. Process failed posts eligible for retry (exponential backoff)
// Retry 1 waits retryDelay, retry 2 waits retryDelay*2, retry 3 waits retryDelay*4, etc.
$query = $db->getQuery(true)
->select('p.*, s.service_type, s.credentials, s.params AS service_params')
->from($db->quoteName('#__mokojoomcross_posts', 'p'))
@@ -81,7 +80,8 @@ class QueueProcessor
. ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id'))
->where($db->quoteName('p.status') . ' = ' . $db->quote('failed'))
->where($db->quoteName('p.retry_count') . ' < ' . $maxRetry)
->where($db->quoteName('p.modified') . ' <= ' . $db->quote($retryAfter))
->where($db->quoteName('p.modified') . ' <= DATE_SUB(NOW(), INTERVAL ('
. (int) $retryDelay . ' * POW(2, ' . $db->quoteName('p.retry_count') . ')) SECOND)')
->where($db->quoteName('s.published') . ' = 1')
->order($db->quoteName('p.modified') . ' ASC')
->setLimit($batchSize);
@@ -420,6 +420,27 @@ class QueueProcessor
$introImage = \Joomla\CMS\Uri\Uri::root() . ltrim($images->image_intro, '/');
}
// Resolve article tags
$tagNames = [];
if (!empty($article->id)) {
$query = $db->getQuery(true)
->select($db->quoteName('t.title'))
->from($db->quoteName('#__tags', 't'))
->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm')
. ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id'))
->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article'))
->where($db->quoteName('m.content_item_id') . ' = ' . (int) $article->id)
->where($db->quoteName('t.published') . ' = 1');
$db->setQuery($query);
$tagNames = $db->loadColumn() ?: [];
}
$tagsComma = implode(', ', $tagNames);
$hashtags = implode(' ', array_map(function ($tag) {
return '#' . preg_replace('/\s+/', '', $tag);
}, $tagNames));
$replacements = [
'{title}' => $article->title ?? '',
'{introtext}' => strip_tags(mb_substr($article->introtext ?? '', 0, 280)),
@@ -429,6 +450,8 @@ class QueueProcessor
'{category}' => $categoryName,
'{author}' => $authorName,
'{date}' => Factory::getDate($article->publish_up ?? 'now')->format('Y-m-d'),
'{tags}' => $tagsComma,
'{hashtags}' => $hashtags,
];
return str_replace(array_keys($replacements), array_values($replacements), $template);
@@ -97,9 +97,11 @@ class DashboardModel extends BaseDatabaseModel
/**
* Get posts-per-service breakdown for the analytics chart.
*
* @param string|null $since Only count posts created on or after this datetime
*
* @return array [['service_type' => '...', 'posted' => N, 'failed' => N, 'queued' => N], ...]
*/
public function getServiceBreakdown(): array
public function getServiceBreakdown(?string $since = null): array
{
$db = $this->getDatabase();
@@ -118,6 +120,10 @@ class DashboardModel extends BaseDatabaseModel
->group($db->quoteName(['s.id', 's.service_type', 's.title']))
->order('total DESC');
if ($since !== null) {
$query->where($db->quoteName('p.created') . ' >= ' . $db->quote($since));
}
$db->setQuery($query);
return $db->loadAssocList() ?: [];
@@ -156,11 +162,12 @@ class DashboardModel extends BaseDatabaseModel
/**
* Get most cross-posted articles.
*
* @param int $limit Number of articles
* @param int $limit Number of articles
* @param string|null $since Only count posts created on or after this datetime
*
* @return array
*/
public function getTopArticles(int $limit = 5): array
public function getTopArticles(int $limit = 5, ?string $since = null): array
{
$db = $this->getDatabase();
@@ -177,6 +184,10 @@ class DashboardModel extends BaseDatabaseModel
->group($db->quoteName(['c.id', 'c.title']))
->order('post_count DESC');
if ($since !== null) {
$query->where($db->quoteName('p.created') . ' >= ' . $db->quote($since));
}
$db->setQuery($query, 0, $limit);
return $db->loadAssocList() ?: [];
@@ -65,6 +65,22 @@ class PostsModel extends ListModel
$query->where($db->quoteName('a.status') . ' = ' . $db->quote($status));
}
// Filter by service
$serviceId = $this->getState('filter.service_id');
if (!empty($serviceId)) {
$query->where($db->quoteName('a.service_id') . ' = ' . (int) $serviceId);
}
// Filter by search (article title or message content)
$search = $this->getState('filter.search');
if (!empty($search)) {
$search = '%' . $db->escape(trim($search), true) . '%';
$query->where('(' . $db->quoteName('c.title') . ' LIKE ' . $db->quote($search)
. ' OR ' . $db->quoteName('a.message') . ' LIKE ' . $db->quote($search) . ')');
}
// Ordering
$orderCol = $this->state->get('list.ordering', 'a.created');
$orderDirn = $this->state->get('list.direction', 'DESC');
@@ -13,6 +13,7 @@ namespace Joomla\Component\MokoJoomCross\Administrator\View\Dashboard;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\Component\MokoJoomCross\Administrator\Helper\MokoJoomCrossHelper;
@@ -26,17 +27,33 @@ class HtmlView extends BaseHtmlView
protected $dailyTrend;
protected $topArticles;
public $sidebar;
public $period;
public function display($tpl = null): void
{
$model = $this->getModel();
// Read period parameter for date range filtering
$this->period = Factory::getApplication()->input->getInt('period', 30);
$validPeriods = [7, 30, 90, 0];
if (!in_array($this->period, $validPeriods, true)) {
$this->period = 30;
}
// Calculate the since date based on period (0 = all time)
$since = null;
if ($this->period > 0) {
$since = Factory::getDate('now - ' . $this->period . ' days')->toSql();
}
$this->stats = $this->get('Stats');
$this->migrationAvailable = $this->get('MigrationAvailable');
$this->recentActivity = $model->getRecentActivity(10);
$this->serviceBreakdown = $model->getServiceBreakdown();
$this->dailyTrend = $model->getDailyTrend(14);
$this->topArticles = $model->getTopArticles(5);
$this->serviceBreakdown = $model->getServiceBreakdown($since);
$this->dailyTrend = $model->getDailyTrend($this->period ?: 365);
$this->topArticles = $model->getTopArticles(5, $since);
$this->addToolbar();
@@ -45,10 +45,26 @@ class HtmlView extends BaseHtmlView
{
ToolbarHelper::title('MokoJoomCross — Post Queue', 'share-alt');
ToolbarHelper::addNew('post.add');
$toolbar = Toolbar::getInstance('toolbar');
$toolbar->standardButton('retry', 'COM_MOKOJOOMCROSS_TOOLBAR_RETRY_FAILED', 'posts.retryFailed')
->icon('icon-refresh')
->listCheck(false);
$toolbar->standardButton('purge', 'COM_MOKOJOOMCROSS_TOOLBAR_PURGE_POSTED', 'posts.purgePosted')
->icon('icon-trash')
->listCheck(false);
ToolbarHelper::deleteList('', 'posts.delete', 'JTOOLBAR_DELETE');
// Export CSV button
$toolbar->appendButton(
'Link',
'download',
'COM_MOKOJOOMCROSS_EXPORT_CSV',
Route::_('index.php?option=com_mokojoomcross&task=posts.exportCsv&format=raw', false)
);
// Dashboard link in toolbar
$toolbar = Toolbar::getInstance('toolbar');
$toolbar->appendButton(
'Link',
'home',
@@ -30,6 +30,16 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler');
</div>
<?php endif; ?>
<?php if ($stats->queued_count > 50) : ?>
<div class="alert alert-warning d-flex align-items-start mb-3">
<span class="icon-exclamation-triangle me-2 mt-1" aria-hidden="true"></span>
<div>
<strong><?php echo Text::_('COM_MOKOJOOMCROSS_DASHBOARD_QUEUE_DEPTH_WARNING_TITLE'); ?></strong><br>
<?php echo Text::sprintf('COM_MOKOJOOMCROSS_DASHBOARD_QUEUE_DEPTH_WARNING', $stats->queued_count); ?>
</div>
</div>
<?php endif; ?>
<div class="row">
<div class="col-lg-9">
<div class="row">
@@ -67,6 +77,71 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler');
</div>
</div>
<!-- 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="dashboard" />
<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="trendChart" 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>
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('trendChart'), {
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; ?>
<?php if ($this->migrationAvailable) : ?>
<div class="alert alert-info">
<h4 class="alert-heading"><?php echo Text::_('COM_MOKOJOOMCROSS_MIGRATION_TITLE'); ?></h4>
@@ -142,6 +142,71 @@ $helpAlias = $helpArticles[$serviceType] ?? '';
</div>
</div>
<?php endif; ?>
<?php if ($serviceId > 0 && !empty($serviceType)) : ?>
<div class="card mt-3">
<div class="card-header bg-secondary text-white">
<h5 class="card-title mb-0">
<span class="icon-plug" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_TITLE'); ?>
</h5>
</div>
<div class="card-body">
<p><?php echo Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_DESC'); ?></p>
<button type="button" id="btn-test-connection" class="btn btn-outline-primary w-100">
<span class="icon-broadcast" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_BUTTON'); ?>
</button>
<div id="test-connection-result" class="mt-2" style="display:none;"></div>
</div>
</div>
<script>
document.getElementById('btn-test-connection').addEventListener('click', function() {
var btn = this;
var resultDiv = document.getElementById('test-connection-result');
btn.disabled = true;
btn.textContent = '<?php echo Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_TESTING'); ?>';
resultDiv.style.display = 'none';
var url = 'index.php?option=com_mokojoomcross&task=service.testConnection&id=<?php echo $serviceId; ?>&format=json';
fetch(url, {
method: 'POST',
headers: {
'X-CSRF-Token': Joomla.getOptions('csrf.token') || '1'
}
})
.then(function(response) { return response.json(); })
.then(function(json) {
resultDiv.style.display = 'block';
resultDiv.textContent = '';
if (json.success) {
var data = json.data || {};
var accountName = data.account_name || '';
var msg = '<?php echo Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_SUCCESS'); ?>';
if (accountName) {
msg += ' \u2014 ' + accountName;
}
resultDiv.className = 'mt-2 alert alert-success';
resultDiv.textContent = msg;
} else {
resultDiv.className = 'mt-2 alert alert-danger';
resultDiv.textContent = json.message || '<?php echo Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_FAILED'); ?>';
}
})
.catch(function() {
resultDiv.style.display = 'block';
resultDiv.className = 'mt-2 alert alert-danger';
resultDiv.textContent = '<?php echo Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_ERROR'); ?>';
})
.finally(function() {
btn.disabled = false;
btn.textContent = '<?php echo Text::_('COM_MOKOJOOMCROSS_TEST_CONNECTION_BUTTON'); ?>';
});
});
</script>
<?php endif; ?>
</div>
</div>
</div>
@@ -137,6 +137,11 @@ class MokoJoomCross extends CMSPlugin implements SubscriberInterface
return;
}
// First-publish-only: skip cross-posting if the article is being updated (not new)
if ($componentParams->get('post_on_first_publish_only', 0) && !$isNew) {
return;
}
$this->dispatchCrossPost($article);
}
@@ -412,6 +417,27 @@ class MokoJoomCross extends CMSPlugin implements SubscriberInterface
$introImage = Uri::root() . ltrim($images->image_intro, '/');
}
// Resolve article tags
$tagNames = [];
if (!empty($article->id)) {
$query = $db->getQuery(true)
->select($db->quoteName('t.title'))
->from($db->quoteName('#__tags', 't'))
->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm')
. ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id'))
->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article'))
->where($db->quoteName('m.content_item_id') . ' = ' . (int) $article->id)
->where($db->quoteName('t.published') . ' = 1');
$db->setQuery($query);
$tagNames = $db->loadColumn() ?: [];
}
$tagsComma = implode(', ', $tagNames);
$hashtags = implode(' ', array_map(function ($tag) {
return '#' . preg_replace('/\s+/', '', $tag);
}, $tagNames));
// Replace placeholders
$replacements = [
'{title}' => $article->title ?? '',
@@ -422,6 +448,8 @@ class MokoJoomCross extends CMSPlugin implements SubscriberInterface
'{category}' => $categoryName,
'{author}' => $authorName,
'{date}' => Factory::getDate($article->publish_up ?? 'now')->format('Y-m-d'),
'{tags}' => $tagsComma,
'{hashtags}' => $hashtags,
];
return str_replace(array_keys($replacements), array_values($replacements), $template);