feat: 10 quick-win enhancements
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:
@@ -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,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);
|
||||
|
||||
Reference in New Issue
Block a user