Compare commits

...

7 Commits

Author SHA1 Message Date
gitea-actions[bot] 8e7381e3ea chore(version): pre-release bump to 01.08.56-dev [skip ci] 2026-06-28 16:58:10 +00:00
jmiller 3eb56f738e Merge pull request 'feat: best time to post analytics view (#165)' (#200) from feature/165-analytics into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
2026-06-28 16:57:56 +00:00
gitea-actions[bot] dba61e3e0c chore(version): auto-bump patch 01.08.55-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
2026-06-28 16:57:47 +00:00
jmiller 9c2dd1bdde feat(#165): add posting analytics with best-time heatmap
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 36s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
- AnalyticsHelper: posting heatmap (7x24 grid), best times ranking,
  per-service breakdown with success rates
- AnalyticsController: AJAX endpoint for dynamic heatmap filtering
- Analytics HtmlView: toolbar, dashboard link, submenu integration
- Template: heatmap table with color intensity, best times cards,
  service breakdown table, service/period filters
- 16 new language strings for analytics UI

Authored-by: Moko Consulting
Closes #165
2026-06-28 11:57:02 -05:00
gitea-actions[bot] ee49fbbaa6 chore(version): pre-release bump to 01.08.54-dev [skip ci] 2026-06-28 16:50:13 +00:00
jmiller 4785a1f5b4 Merge pull request 'feat: visual post calendar admin view (#160)' (#198) from feature/160-visual-calendar into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s
2026-06-28 16:50:03 +00:00
jmiller b6202a6a40 feat: add visual post calendar admin view (#160)
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Failing after 3s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 28s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Add a monthly calendar grid view to the admin component showing
scheduled, queued, and posted cross-posts with color-coded status
badges. Includes month-by-month navigation and today highlighting.

New files:
- CalendarController, CalendarModel, Calendar HtmlView, calendar template

Modified files:
- MokoSuiteCrossHelper: added Calendar to submenu
- Language file: added calendar strings
- CHANGELOG.md: documented new feature

Authored-by: Moko Consulting
2026-06-28 11:49:23 -05:00
65 changed files with 1059 additions and 224 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.08.53
# VERSION: 01.08.56
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+7 -1
View File
@@ -2,6 +2,12 @@
## [Unreleased]
### Added
- **Visual post calendar**: Monthly calendar grid view showing scheduled, queued, and posted cross-posts with status badges (#160)
- **Calendar navigation**: Month-by-month navigation with today highlighting (#160)
- **Posting analytics**: Best time to post heatmap with day-of-week and hour-of-day breakdown (#165)
- **Analytics service filter**: Filter heatmap and stats by service type with configurable date range
- **Analytics service breakdown**: Per-service success rate, failure count, and average posts per day
- **Analytics AJAX endpoint**: JSON heatmap data for dynamic filtering without page reload
- **Social image generator**: Generate Open Graph images with article title overlay using PHP GD library (#157)
- **Social image config**: Background color, text color, overlay style, and site name override in component options (#157)
- **AI caption generation**: Generate platform-optimized cross-post captions from article content using Claude or OpenAI (#161)
@@ -98,7 +104,7 @@
## [01.03.00] --- 2026-06-21
<!-- VERSION: 01.08.53 -->
<!-- VERSION: 01.08.56 -->
All notable changes to MokoSuiteCross will be documented in this file.
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation
REPO: https://github.com/mokoconsulting-tech/Template-Joomla/
VERSION: 01.08.53
VERSION: 01.08.56
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Community expectations and enforcement guidelines
NOTE: Adapted with attribution from the Contributor Covenant v2.1
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoSuiteCross
<!-- VERSION: 01.08.53 -->
<!-- VERSION: 01.08.56 -->
Cross-posting Joomla content to social media, email marketing, and chat platforms for Joomla 5/6.
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
PATH: /SECURITY.md
VERSION: 01.08.53
VERSION: 01.08.56
BRIEF: Security vulnerability reporting and handling policy
-->
+33 -17
View File
@@ -267,37 +267,53 @@
</fieldset>
<fieldset name="social_image" label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE">
<field
name="social_image_enabled"
type="radio"
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_ENABLED"
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_ENABLED_DESC"
default="0"
class="btn-group">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="social_image_bg_color"
type="color"
type="text"
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR"
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR_DESC"
default="#1a1a2e"
showon="social_image_enabled:1"
/>
<field
name="social_image_text_color"
type="color"
type="text"
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR"
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR_DESC"
default="#ffffff"
showon="social_image_enabled:1"
/>
<field
name="social_image_overlay"
type="list"
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY"
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_DESC"
default="dark">
<option value="none">COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_NONE</option>
<option value="light">COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_LIGHT</option>
<option value="dark">COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_OVERLAY_DARK</option>
name="social_image_font_size"
type="number"
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_FONT_SIZE"
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_FONT_SIZE_DESC"
default="48"
min="24"
max="96"
showon="social_image_enabled:1"
/>
<field
name="social_image_show_site_name"
type="radio"
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SHOW_SITE_NAME"
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SHOW_SITE_NAME_DESC"
default="1"
class="btn-group"
showon="social_image_enabled:1">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="social_image_site_name"
type="text"
label="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SITE_NAME"
description="COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SITE_NAME_DESC"
hint="Leave blank to use Joomla site name"
/>
</fieldset>
<fieldset name="category_rules" label="COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES">
@@ -586,7 +586,49 @@ COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SITE_NAME="Site Name Override"
COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SITE_NAME_DESC="Custom site name shown at the bottom of generated images. Leave blank to use the Joomla site name."
COM_MOKOSUITECROSS_AI_NOT_CONFIGURED="AI is not configured. Go to Options to set up a provider and API key."
; Analytics
COM_MOKOSUITECROSS_SUBMENU_ANALYTICS="Analytics"
COM_MOKOSUITECROSS_ANALYTICS_PERIOD="Time Period"
COM_MOKOSUITECROSS_ANALYTICS_SERVICE_FILTER="Service"
COM_MOKOSUITECROSS_ANALYTICS_ALL_SERVICES="All Services"
COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES="Best Times to Post"
COM_MOKOSUITECROSS_ANALYTICS_HEATMAP="Engagement Heatmap"
COM_MOKOSUITECROSS_ANALYTICS_HOURLY="Hourly Distribution"
COM_MOKOSUITECROSS_ANALYTICS_DAILY="Day of Week Distribution"
COM_MOKOSUITECROSS_ANALYTICS_NO_DATA="Not enough posting data to generate recommendations. Post at least 3 times per time slot over the selected period."
COM_MOKOSUITECROSS_ANALYTICS_POSTS_SUCCESS="%d of %d successful"
COM_MOKOSUITECROSS_ANALYTICS_DAY_SUN="Sun"
COM_MOKOSUITECROSS_ANALYTICS_DAY_MON="Mon"
COM_MOKOSUITECROSS_ANALYTICS_DAY_TUE="Tue"
COM_MOKOSUITECROSS_ANALYTICS_DAY_WED="Wed"
COM_MOKOSUITECROSS_ANALYTICS_DAY_THU="Thu"
COM_MOKOSUITECROSS_ANALYTICS_DAY_FRI="Fri"
COM_MOKOSUITECROSS_ANALYTICS_DAY_SAT="Sat"
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_HIGH="High success rate"
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_MEDIUM="Medium success rate"
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_LOW="Low success rate"
COM_MOKOSUITECROSS_ANALYTICS_LEGEND_NONE="No data"
COM_MOKOSUITECROSS_PERIOD_180_DAYS="Last 180 days"
COM_MOKOSUITECROSS_PERIOD_365_DAYS="Last 365 days"
; Category Rules
COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES="Category Rules"
COM_MOKOSUITECROSS_CONFIG_CATEGORY_RULES_NOTE="Category Routing"
COM_MOKOSUITECROSS_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 #__mokosuitecross_category_rules. A full admin UI will be added in a future release."
; Posting Analytics
COM_MOKOSUITECROSS_ANALYTICS_FILTER_SERVICE="Service"
COM_MOKOSUITECROSS_ANALYTICS_ALL_SERVICES="All Services"
COM_MOKOSUITECROSS_ANALYTICS_PERIOD="Period"
COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES="Best Times to Post"
COM_MOKOSUITECROSS_ANALYTICS_POSTS_COUNT="%d posts"
COM_MOKOSUITECROSS_ANALYTICS_HEATMAP="Posting Heatmap"
COM_MOKOSUITECROSS_ANALYTICS_NO_DATA="No posting data available for the selected period."
COM_MOKOSUITECROSS_ANALYTICS_LESS="Less"
COM_MOKOSUITECROSS_ANALYTICS_MORE="More"
COM_MOKOSUITECROSS_ANALYTICS_SERVICE_BREAKDOWN="Service Breakdown"
COM_MOKOSUITECROSS_ANALYTICS_SERVICE="Service"
COM_MOKOSUITECROSS_ANALYTICS_TOTAL="Total"
COM_MOKOSUITECROSS_ANALYTICS_SUCCESS="Success"
COM_MOKOSUITECROSS_ANALYTICS_FAILED="Failed"
COM_MOKOSUITECROSS_ANALYTICS_SUCCESS_RATE="Success Rate"
COM_MOKOSUITECROSS_ANALYTICS_AVG_PER_DAY="Avg/Day"
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="component" method="upgrade">
<name>com_mokosuitecross</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -0,0 +1 @@
/* 01.08.54 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.55 — no schema changes */
@@ -0,0 +1 @@
/* 01.08.56 — no schema changes */
@@ -0,0 +1,52 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @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\MokoSuiteCross\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Session\Session;
use Joomla\Component\MokoSuiteCross\Administrator\Helper\AnalyticsHelper;
class AnalyticsController extends BaseController
{
public function getHeatmapData(): void
{
if (!Session::checkToken('get')) {
echo json_encode(['success' => false, 'error' => 'Invalid token']);
$this->app->close();
return;
}
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitecross')) {
echo json_encode(['success' => false, 'error' => 'Permission denied']);
$this->app->close();
return;
}
$serviceType = $this->input->getCmd('service_type', '');
$days = $this->input->getInt('days', 90);
$heatmap = AnalyticsHelper::getPostingHeatmap($serviceType, $days);
$bestTimes = AnalyticsHelper::getBestTimes($serviceType, $days);
$this->app->setHeader('Content-Type', 'application/json; charset=utf-8');
echo json_encode([
'success' => true,
'heatmap' => $heatmap,
'best_times' => $bestTimes,
]);
$this->app->close();
}
}
@@ -0,0 +1,24 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @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\MokoSuiteCross\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
class CalendarController extends BaseController
{
public function display($cachable = false, $urlparams = []): static
{
return parent::display($cachable, $urlparams);
}
}
@@ -0,0 +1,160 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @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\MokoSuiteCross\Administrator\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
class AnalyticsHelper
{
private static array $dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
public static function getPostingHeatmap(string $serviceType = '', int $days = 90): array
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('DAYOFWEEK(' . $db->quoteName('p.posted_at') . ') - 1 AS dow')
->select('HOUR(' . $db->quoteName('p.posted_at') . ') AS hr')
->select('COUNT(*) AS cnt')
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
->where($db->quoteName('p.status') . ' = ' . $db->quote('posted'))
->where($db->quoteName('p.posted_at') . ' IS NOT NULL');
if ($days > 0) {
$since = Factory::getDate('now - ' . $days . ' days')->toSql();
$query->where($db->quoteName('p.posted_at') . ' >= ' . $db->quote($since));
}
if ($serviceType !== '') {
$query->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id'))
->where($db->quoteName('s.service_type') . ' = ' . $db->quote($serviceType));
}
$query->group('dow, hr')
->order('dow ASC, hr ASC');
$db->setQuery($query);
$rows = $db->loadObjectList();
$grid = [];
for ($d = 0; $d < 7; $d++) {
$grid[$d] = array_fill(0, 24, 0);
}
foreach ($rows as $row) {
$grid[(int) $row->dow][(int) $row->hr] = (int) $row->cnt;
}
return $grid;
}
public static function getBestTimes(string $serviceType = '', int $days = 90, int $limit = 5): array
{
$grid = self::getPostingHeatmap($serviceType, $days);
$slots = [];
foreach ($grid as $dow => $hours) {
foreach ($hours as $hour => $count) {
if ($count > 0) {
$slots[] = [
'day' => self::$dayNames[$dow],
'hour' => $hour,
'count' => $count,
'label' => self::$dayNames[$dow] . ' ' . self::formatHour($hour),
];
}
}
}
usort($slots, static fn($a, $b) => $b['count'] <=> $a['count']);
return \array_slice($slots, 0, $limit);
}
public static function getServiceBreakdown(int $days = 30): array
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('s.service_type'))
->select($db->quoteName('s.title', 'service_title'))
->select('COUNT(*) AS total')
->select('SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('posted') . ' THEN 1 ELSE 0 END) AS success')
->select('SUM(CASE WHEN ' . $db->quoteName('p.status') . ' = ' . $db->quote('failed') . ' THEN 1 ELSE 0 END) AS failed')
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
->join('INNER', $db->quoteName('#__mokosuitecross_services', 's') . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id'));
if ($days > 0) {
$since = Factory::getDate('now - ' . $days . ' days')->toSql();
$query->where($db->quoteName('p.created') . ' >= ' . $db->quote($since));
}
$query->group($db->quoteName(['s.service_type', 's.title']))
->order('total DESC');
$db->setQuery($query);
$rows = $db->loadObjectList();
$result = [];
foreach ($rows as $row) {
$total = (int) $row->total;
$success = (int) $row->success;
$result[] = [
'service_type' => $row->service_type,
'service_title' => $row->service_title,
'total' => $total,
'success' => $success,
'failed' => (int) $row->failed,
'success_rate' => $total > 0 ? round(($success / $total) * 100, 1) : 0.0,
'avg_per_day' => $days > 0 ? round($total / $days, 1) : 0.0,
];
}
return $result;
}
public static function getServiceTypes(): array
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('DISTINCT ' . $db->quoteName('service_type'))
->from($db->quoteName('#__mokosuitecross_services'))
->where($db->quoteName('published') . ' = 1')
->order($db->quoteName('service_type') . ' ASC');
$db->setQuery($query);
return $db->loadColumn() ?: [];
}
private static function formatHour(int $hour): string
{
if ($hour === 0) {
return '12:00 AM';
}
if ($hour < 12) {
return $hour . ':00 AM';
}
if ($hour === 12) {
return '12:00 PM';
}
return ($hour - 12) . ':00 PM';
}
}
@@ -13,195 +13,169 @@ namespace Joomla\Component\MokoSuiteCross\Administrator\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
class SocialImageHelper
{
private const WIDTH = 1200;
private const WIDTH = 1200;
private const HEIGHT = 630;
private const PADDING = 60;
private const MAX_TITLE_CHARS_PER_LINE = 30;
public static function generate(string $title, string $siteName, ?string $backgroundPath = null, array $options = []): string
/**
* Generate a branded social/OG image with text overlay.
*
* @param string $title Article title to render on the image
* @param string $siteName Site name for branding watermark
* @param array $config Rendering config: bg_color, text_color, font_size, show_site_name
*
* @return array ['success' => bool, 'image_url' => string, 'error' => string]
*/
public static function generate(string $title, string $siteName, array $config): array
{
if (!\function_exists('imagecreatetruecolor')) {
throw new \RuntimeException('PHP GD extension is required for social image generation.');
return ['success' => false, 'error' => 'PHP GD extension is not available'];
}
$bgColor = $options['bg_color'] ?? '#1a1a2e';
$textColor = $options['text_color'] ?? '#ffffff';
$overlayType = $options['overlay'] ?? 'dark';
$fontSize = (int) ($options['font_size'] ?? 36);
$bgColor = $config['bg_color'] ?? '#1a1a2e';
$textColor = $config['text_color'] ?? '#ffffff';
$fontSize = (int) ($config['font_size'] ?? 48);
$showSiteName = (bool) ($config['show_site_name'] ?? true);
$fontSize = max(24, min(96, $fontSize));
$image = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
if ($image === false) {
throw new \RuntimeException('Failed to create image canvas.');
}
$bgRgb = self::hexToRgb($bgColor);
$bg = imagecolorallocate($image, $bgRgb[0], $bgRgb[1], $bgRgb[2]);
imagefill($image, 0, 0, $bg);
if ($backgroundPath !== null && is_file($backgroundPath)) {
self::drawBackground($image, $backgroundPath);
}
if ($overlayType !== 'none') {
self::drawOverlay($image, $overlayType);
return ['success' => false, 'error' => 'Failed to create image canvas'];
}
$bgRgb = self::hexToRgb($bgColor);
$textRgb = self::hexToRgb($textColor);
$textCol = imagecolorallocate($image, $textRgb[0], $textRgb[1], $textRgb[2]);
$fontPath = self::findFont();
$bg = imagecolorallocate($image, $bgRgb[0], $bgRgb[1], $bgRgb[2]);
$text = imagecolorallocate($image, $textRgb[0], $textRgb[1], $textRgb[2]);
if ($fontPath !== null) {
self::drawTextTtf($image, $title, $siteName, $fontPath, $fontSize, $textCol);
imagefilledrectangle($image, 0, 0, self::WIDTH - 1, self::HEIGHT - 1, $bg);
$fontFile = self::findFont();
if ($fontFile !== null) {
self::renderTtfText($image, $title, $text, $fontSize, $fontFile);
if ($showSiteName && $siteName !== '') {
$siteSize = (int) round($fontSize * 0.45);
$siteBox = imagettfbbox($siteSize, 0, $fontFile, $siteName);
$siteX = self::WIDTH - ($siteBox[2] - $siteBox[0]) - 40;
$siteY = self::HEIGHT - 30;
imagettftext($image, $siteSize, 0, $siteX, $siteY, $text, $fontFile, $siteName);
}
} else {
self::drawTextFallback($image, $title, $siteName, $textCol);
}
self::renderFallbackText($image, $title, $text);
$tmpDir = Factory::getApplication()->get('tmp_path', JPATH_ROOT . '/tmp');
$outPath = $tmpDir . '/social_' . md5($title . $siteName . microtime()) . '.png';
imagepng($image, $outPath, 6);
imagedestroy($image);
return $outPath;
}
private static function drawBackground(\GdImage $image, string $path): void
{
$info = @getimagesize($path);
if ($info === false) {
return;
}
$source = match ($info[2]) {
IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
IMAGETYPE_PNG => @imagecreatefrompng($path),
IMAGETYPE_WEBP => \function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : false,
default => false,
};
if ($source === false) {
return;
}
$srcW = imagesx($source);
$srcH = imagesy($source);
$scale = max(self::WIDTH / $srcW, self::HEIGHT / $srcH);
$newW = (int) ($srcW * $scale);
$newH = (int) ($srcH * $scale);
$dstX = (int) ((self::WIDTH - $newW) / 2);
$dstY = (int) ((self::HEIGHT - $newH) / 2);
imagecopyresampled($image, $source, $dstX, $dstY, 0, 0, $newW, $newH, $srcW, $srcH);
imagedestroy($source);
}
private static function drawOverlay(\GdImage $image, string $type): void
{
$opacity = ($type === 'light') ? 80 : 160;
$color = imagecolorallocatealpha($image, 0, 0, 0, 127 - (int) ($opacity * 127 / 255));
if ($color === false) {
return;
}
imagesavealpha($image, true);
imagefilledrectangle($image, 0, 0, self::WIDTH - 1, self::HEIGHT - 1, $color);
}
private static function drawTextTtf(\GdImage $image, string $title, string $siteName, string $fontPath, int $fontSize, int $color): void
{
$lines = self::wordWrap($title, $fontPath, $fontSize, self::WIDTH - self::PADDING * 2);
$lineH = (int) ($fontSize * 1.4);
$totalH = \count($lines) * $lineH;
$startY = (int) ((self::HEIGHT - $totalH) / 2) + $fontSize;
foreach ($lines as $i => $line) {
$box = imagettfbbox($fontSize, 0, $fontPath, $line);
$lineW = abs($box[2] - $box[0]);
$x = (int) ((self::WIDTH - $lineW) / 2);
imagettftext($image, $fontSize, 0, $x, $startY + $i * $lineH, $color, $fontPath, $line);
}
$siteSize = (int) ($fontSize * 0.5);
$box = imagettfbbox($siteSize, 0, $fontPath, $siteName);
$siteW = abs($box[2] - $box[0]);
$siteX = (int) ((self::WIDTH - $siteW) / 2);
$siteY = self::HEIGHT - self::PADDING;
$siteCol = imagecolorallocatealpha($image, 255, 255, 255, 40);
if ($siteCol !== false) {
imagettftext($image, $siteSize, 0, $siteX, $siteY, $siteCol, $fontPath, $siteName);
}
}
private static function drawTextFallback(\GdImage $image, string $title, string $siteName, int $color): void
{
$maxChars = (int) (self::WIDTH / 10);
$wrapped = wordwrap($title, $maxChars, "\n", true);
$lines = explode("\n", $wrapped);
$lineH = 20;
$totalH = \count($lines) * $lineH;
$startY = (int) ((self::HEIGHT - $totalH) / 2);
foreach ($lines as $i => $line) {
$lineW = \strlen($line) * 9;
$x = max(self::PADDING, (int) ((self::WIDTH - $lineW) / 2));
imagestring($image, 5, $x, $startY + $i * $lineH, $line, $color);
}
$siteW = \strlen($siteName) * 7;
$siteX = max(self::PADDING, (int) ((self::WIDTH - $siteW) / 2));
$siteY = self::HEIGHT - 40;
$gray = imagecolorallocate($image, 180, 180, 180);
if ($gray !== false) {
imagestring($image, 3, $siteX, $siteY, $siteName, $gray);
}
}
private static function wordWrap(string $text, string $fontPath, int $fontSize, int $maxWidth): array
{
$words = explode(' ', $text);
$lines = [];
$line = '';
foreach ($words as $word) {
$test = ($line === '') ? $word : $line . ' ' . $word;
$box = imagettfbbox($fontSize, 0, $fontPath, $test);
$testW = abs($box[2] - $box[0]);
if ($testW > $maxWidth && $line !== '') {
$lines[] = $line;
$line = $word;
} else {
$line = $test;
if ($showSiteName && $siteName !== '') {
$siteX = self::WIDTH - (\strlen($siteName) * imagefontwidth(3)) - 40;
$siteY = self::HEIGHT - 30;
imagestring($image, 3, $siteX, $siteY, $siteName, $text);
}
}
if ($line !== '') {
$lines[] = $line;
$outputDir = JPATH_ROOT . '/media/com_mokosuitecross/social';
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
$hash = hash('sha256', $title . $bgColor . $textColor . $fontSize);
$filename = $hash . '.png';
$filePath = $outputDir . '/' . $filename;
if (!imagepng($image, $filePath, 6)) {
imagedestroy($image);
return ['success' => false, 'error' => 'Failed to save image file'];
}
imagedestroy($image);
$imageUrl = 'media/com_mokosuitecross/social/' . $filename;
return ['success' => true, 'image_url' => $imageUrl];
}
private static function renderTtfText(\GdImage $image, string $title, int $color, int $fontSize, string $fontFile): void
{
$maxWidth = self::WIDTH - 120;
$lines = self::wordWrapTtf($title, $fontFile, $fontSize, $maxWidth);
$lineHeight = (int) round($fontSize * 1.4);
$totalHeight = \count($lines) * $lineHeight;
$startY = (int) round((self::HEIGHT - $totalHeight) / 2) + $fontSize;
foreach ($lines as $i => $line) {
$y = $startY + ($i * $lineHeight);
imagettftext($image, $fontSize, 0, 60, $y, $color, $fontFile, $line);
}
}
private static function renderFallbackText(\GdImage $image, string $title, int $color): void
{
$font = 5;
$charWidth = imagefontwidth($font);
$charHeight = imagefontheight($font);
$maxChars = (int) floor((self::WIDTH - 120) / $charWidth);
$lines = wordwrap($title, $maxChars, "\n", true);
$lineArray = explode("\n", $lines);
$lineHeight = $charHeight + 8;
$totalHeight = \count($lineArray) * $lineHeight;
$startY = (int) round((self::HEIGHT - $totalHeight) / 2);
foreach ($lineArray as $i => $line) {
$y = $startY + ($i * $lineHeight);
imagestring($image, $font, 60, $y, $line, $color);
}
}
/**
* Word-wrap text for TTF rendering at a given pixel width.
*
* @return string[]
*/
private static function wordWrapTtf(string $text, string $fontFile, int $fontSize, int $maxWidth): array
{
$words = explode(' ', $text);
$lines = [];
$currentLine = '';
foreach ($words as $word) {
$testLine = $currentLine === '' ? $word : $currentLine . ' ' . $word;
$box = imagettfbbox($fontSize, 0, $fontFile, $testLine);
$width = abs($box[2] - $box[0]);
if ($width > $maxWidth && $currentLine !== '') {
$lines[] = $currentLine;
$currentLine = $word;
} else {
$currentLine = $testLine;
}
}
if ($currentLine !== '') {
$lines[] = $currentLine;
}
return $lines ?: [$text];
}
/**
* Locate a usable TTF font file -- check common system locations.
*/
private static function findFont(): ?string
{
$candidates = [
JPATH_ROOT . '/media/com_mokosuitecross/fonts/OpenSans-Bold.ttf',
JPATH_ROOT . '/media/com_mokosuitecross/fonts/Roboto-Bold.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
'C:/Windows/Fonts/arialbd.ttf',
'/usr/share/fonts/TTF/DejaVuSans-Bold.ttf',
'C:/Windows/Fonts/arial.ttf',
'C:/Windows/Fonts/segoeui.ttf',
];
foreach ($candidates as $path) {
@@ -213,6 +187,9 @@ class SocialImageHelper
return null;
}
/**
* @return int[] [r, g, b]
*/
private static function hexToRgb(string $hex): array
{
$hex = ltrim($hex, '#');
@@ -0,0 +1,67 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @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\MokoSuiteCross\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class CalendarModel extends BaseDatabaseModel
{
/**
* Get cross-post events for a given month, grouped by date.
*
* @param int $year Four-digit year
* @param int $month Month number (1-12)
*
* @return array Associative array keyed by Y-m-d, each value an array of event objects
*/
public function getEvents(int $year, int $month): array
{
$db = $this->getDatabase();
$firstDay = sprintf('%04d-%02d-01', $year, $month);
$lastDay = date('Y-m-t', strtotime($firstDay));
$dateExpr = 'COALESCE('
. $db->quoteName('p.scheduled_at') . ', '
. $db->quoteName('p.posted_at') . ', '
. $db->quoteName('p.created') . ')';
$query = $db->getQuery(true)
->select([
'DATE(' . $dateExpr . ') AS event_date',
$db->quoteName('p.status'),
$db->quoteName('s.service_type'),
$db->quoteName('c.title', 'article_title'),
])
->from($db->quoteName('#__mokosuitecross_posts', 'p'))
->join('LEFT', $db->quoteName('#__mokosuitecross_services', 's')
. ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id'))
->join('LEFT', $db->quoteName('#__content', 'c')
. ' ON ' . $db->quoteName('c.id') . ' = ' . $db->quoteName('p.article_id'))
->where('DATE(' . $dateExpr . ') >= ' . $db->quote($firstDay))
->where('DATE(' . $dateExpr . ') <= ' . $db->quote($lastDay))
->order('DATE(' . $dateExpr . ') ASC, ' . $db->quoteName('p.created') . ' ASC');
$db->setQuery($query);
$rows = $db->loadObjectList() ?: [];
$grouped = [];
foreach ($rows as $row) {
$grouped[$row->event_date][] = $row;
}
return $grouped;
}
}
@@ -0,0 +1,63 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @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\MokoSuiteCross\Administrator\View\Analytics;
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\MokoSuiteCross\Administrator\Helper\AnalyticsHelper;
use Joomla\Component\MokoSuiteCross\Administrator\Helper\MokoSuiteCrossHelper;
class HtmlView extends BaseHtmlView
{
public array $heatmap = [];
public array $bestTimes = [];
public array $serviceBreakdown = [];
public array $serviceTypes = [];
public string $serviceFilter = '';
public int $days = 90;
public function display($tpl = null): void
{
$input = Factory::getApplication()->input;
$this->serviceFilter = $input->getCmd('service_type', '');
$this->days = $input->getInt('days', 90);
$this->heatmap = AnalyticsHelper::getPostingHeatmap($this->serviceFilter, $this->days);
$this->bestTimes = AnalyticsHelper::getBestTimes($this->serviceFilter, $this->days);
$this->serviceBreakdown = AnalyticsHelper::getServiceBreakdown($this->days);
$this->serviceTypes = AnalyticsHelper::getServiceTypes();
$this->addToolbar();
MokoSuiteCrossHelper::addSubmenu('analytics');
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title('MokoSuiteCross -- Posting Analytics', 'chart');
$toolbar = Toolbar::getInstance('toolbar');
$toolbar->appendButton(
'Link',
'home',
'COM_MOKOSUITECROSS_SUBMENU_DASHBOARD',
Route::_('index.php?option=com_mokosuitecross&view=dashboard', false)
);
}
}
@@ -0,0 +1,65 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @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\MokoSuiteCross\Administrator\View\Calendar;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\Component\MokoSuiteCross\Administrator\Helper\MokoSuiteCrossHelper;
class HtmlView extends BaseHtmlView
{
public int $year;
public int $month;
public array $events;
public $sidebar;
public function display($tpl = null): void
{
$input = Factory::getApplication()->input;
$this->year = $input->getInt('year', (int) date('Y'));
$this->month = $input->getInt('month', (int) date('n'));
if ($this->month < 1 || $this->month > 12) {
$this->month = (int) date('n');
}
if ($this->year < 2000 || $this->year > 2100) {
$this->year = (int) date('Y');
}
$model = $this->getModel();
$this->events = $model->getEvents($this->year, $this->month);
$this->addToolbar();
MokoSuiteCrossHelper::addSubmenu('calendar');
$this->sidebar = \Joomla\CMS\HTML\Sidebar::render();
parent::display($tpl);
}
protected function addToolbar(): void
{
$canDo = MokoSuiteCrossHelper::getActions();
ToolbarHelper::title('MokoSuiteCross -- Post Calendar', 'calendar');
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokosuitecross&view=dashboard');
if ($canDo->get('core.admin')) {
ToolbarHelper::preferences('com_mokosuitecross');
}
}
}
@@ -0,0 +1,231 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @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\Language\Text;
/** @var \Joomla\Component\MokoSuiteCross\Administrator\View\Analytics\HtmlView $this */
$dayNames = [
1 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_SUN'),
2 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_MON'),
3 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_TUE'),
4 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_WED'),
5 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_THU'),
6 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_FRI'),
7 => Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAY_SAT'),
];
?>
<form method="get" class="mb-3">
<input type="hidden" name="option" value="com_mokosuitecross" />
<input type="hidden" name="view" value="analytics" />
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label" for="analytics-period"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_PERIOD'); ?></label>
<select name="period" id="analytics-period" class="form-select" onchange="this.form.submit();">
<option value="7" <?php echo $this->period == 7 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOSUITECROSS_PERIOD_7_DAYS'); ?></option>
<option value="30" <?php echo $this->period == 30 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOSUITECROSS_PERIOD_30_DAYS'); ?></option>
<option value="90" <?php echo $this->period == 90 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOSUITECROSS_PERIOD_90_DAYS'); ?></option>
<option value="180" <?php echo $this->period == 180 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOSUITECROSS_PERIOD_180_DAYS'); ?></option>
<option value="365" <?php echo $this->period == 365 ? 'selected' : ''; ?>><?php echo Text::_('COM_MOKOSUITECROSS_PERIOD_365_DAYS'); ?></option>
</select>
</div>
<div class="col-auto">
<label class="form-label" for="analytics-service"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_SERVICE_FILTER'); ?></label>
<select name="service_id" id="analytics-service" class="form-select" onchange="this.form.submit();">
<option value="0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_ALL_SERVICES'); ?></option>
<?php foreach ($this->services as $svc) : ?>
<option value="<?php echo (int) $svc['id']; ?>" <?php echo $this->serviceId == $svc['id'] ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($svc['title'] . ' (' . ucfirst($svc['service_type']) . ')'); ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
</form>
<?php if (!empty($this->bestTimes)) : ?>
<div class="card mb-3">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES'); ?></h5>
</div>
<div class="card-body">
<div class="row">
<?php foreach ($this->bestTimes as $bt) :
$rate = (int) $bt['total'] > 0 ? round(((int) $bt['success'] / (int) $bt['total']) * 100) : 0;
?>
<div class="col-sm-6 col-md-4 col-lg mb-2">
<div class="border rounded p-3 text-center h-100">
<div class="fw-bold text-primary"><?php echo $dayNames[(int) $bt['dow']]; ?></div>
<div class="display-6"><?php echo sprintf('%02d:00', (int) $bt['hour_of_day']); ?></div>
<div class="text-muted small">
<?php echo Text::sprintf('COM_MOKOSUITECROSS_ANALYTICS_POSTS_SUCCESS', (int) $bt['success'], (int) $bt['total']); ?>
</div>
<span class="badge bg-success"><?php echo $rate; ?>%</span>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php else : ?>
<div class="alert alert-info mb-3">
<?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_NO_DATA'); ?>
</div>
<?php endif; ?>
<div class="card mb-3">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_HEATMAP'); ?></h5>
</div>
<div class="card-body" style="overflow-x: auto;">
<table class="table table-sm table-bordered text-center mb-0" style="min-width: 700px;">
<thead>
<tr>
<th></th>
<?php for ($h = 0; $h < 24; $h++) : ?>
<th class="small" style="width: 3.8%;"><?php echo sprintf('%02d', $h); ?></th>
<?php endfor; ?>
</tr>
</thead>
<tbody>
<?php
$maxTotal = 1;
foreach ($this->heatmap as $dayData) {
foreach ($dayData as $cell) {
if ($cell['total'] > $maxTotal) {
$maxTotal = $cell['total'];
}
}
}
foreach ($this->heatmap as $dow => $hours) : ?>
<tr>
<th class="text-nowrap small"><?php echo $dayNames[$dow]; ?></th>
<?php foreach ($hours as $hour => $cell) :
$intensity = $maxTotal > 0 ? $cell['total'] / $maxTotal : 0;
$r = $g = $b = 255;
if ($cell['total'] > 0) {
$rate = $cell['rate'];
if ($rate >= 80) {
$r = (int) (255 - (155 * $intensity));
$g = (int) (255 - (100 * $intensity));
$b = (int) (255 - (155 * $intensity));
} elseif ($rate >= 50) {
$r = (int) (255 - (50 * $intensity));
$g = (int) (255 - (50 * $intensity));
$b = (int) (255 - (200 * $intensity));
} else {
$r = (int) (255 - (35 * $intensity));
$g = (int) (255 - (200 * $intensity));
$b = (int) (255 - (200 * $intensity));
}
}
?>
<td style="background: rgb(<?php echo "$r,$g,$b"; ?>); cursor: default;"
title="<?php echo $dayNames[$dow] . ' ' . sprintf('%02d:00', $hour) . ': ' . $cell['total'] . ' posts, ' . $cell['success'] . ' success (' . $cell['rate'] . '%)'; ?>">
<?php if ($cell['total'] > 0) : ?>
<small><?php echo $cell['total']; ?></small>
<?php endif; ?>
</td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="d-flex justify-content-center gap-3 mt-2 small text-muted">
<span><span style="display: inline-block; width: 12px; height: 12px; background: rgb(100,155,100); border: 1px solid #ccc;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_LEGEND_HIGH'); ?></span>
<span><span style="display: inline-block; width: 12px; height: 12px; background: rgb(205,205,55); border: 1px solid #ccc;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_LEGEND_MEDIUM'); ?></span>
<span><span style="display: inline-block; width: 12px; height: 12px; background: rgb(220,55,55); border: 1px solid #ccc;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_LEGEND_LOW'); ?></span>
<span><span style="display: inline-block; width: 12px; height: 12px; background: rgb(255,255,255); border: 1px solid #ccc;"></span> <?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_LEGEND_NONE'); ?></span>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-6">
<div class="card mb-3">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_HOURLY'); ?></h5>
</div>
<div class="card-body">
<canvas id="hourlyChart" height="200"></canvas>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card mb-3">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOSUITECROSS_ANALYTICS_DAILY'); ?></h5>
</div>
<div class="card-body">
<canvas id="dayChart" height="200"></canvas>
</div>
</div>
</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 hourlyData = <?php echo json_encode(array_values($this->hourlyDistribution)); ?>;
var hourLabels = [], hourSuccess = [], hourFailed = [];
for (var h = 0; h < 24; h++) {
hourLabels.push(('0' + h).slice(-2) + ':00');
var found = hourlyData.find(function(d) { return parseInt(d.hour_of_day, 10) === h; });
hourSuccess.push(found ? parseInt(found.success, 10) : 0);
hourFailed.push(found ? parseInt(found.failed, 10) : 0);
}
new Chart(document.getElementById('hourlyChart'), {
type: 'bar',
data: {
labels: hourLabels,
datasets: [
{ label: '<?php echo Text::_("COM_MOKOSUITECROSS_DASHBOARD_POSTED", true); ?>', data: hourSuccess, backgroundColor: 'rgba(25,135,84,0.7)' },
{ label: '<?php echo Text::_("COM_MOKOSUITECROSS_DASHBOARD_FAILED", true); ?>', data: hourFailed, backgroundColor: 'rgba(220,53,69,0.7)' }
]
},
options: {
responsive: true,
scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true, ticks: { stepSize: 1 } } },
plugins: { legend: { position: 'bottom' } }
}
});
var dayData = <?php echo json_encode(array_values($this->dayDistribution)); ?>;
var dayLabels = <?php echo json_encode(array_values($dayNames)); ?>;
var daySuccess = [], dayFailed = [];
for (var d = 1; d <= 7; d++) {
var found = dayData.find(function(r) { return parseInt(r.dow, 10) === d; });
daySuccess.push(found ? parseInt(found.success, 10) : 0);
dayFailed.push(found ? parseInt(found.failed, 10) : 0);
}
new Chart(document.getElementById('dayChart'), {
type: 'bar',
data: {
labels: dayLabels,
datasets: [
{ label: '<?php echo Text::_("COM_MOKOSUITECROSS_DASHBOARD_POSTED", true); ?>', data: daySuccess, backgroundColor: 'rgba(25,135,84,0.7)' },
{ label: '<?php echo Text::_("COM_MOKOSUITECROSS_DASHBOARD_FAILED", true); ?>', data: dayFailed, backgroundColor: 'rgba(220,53,69,0.7)' }
]
},
options: {
responsive: true,
scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true, ticks: { stepSize: 1 } } },
plugins: { legend: { position: 'bottom' } }
}
});
});
</script>
@@ -0,0 +1,129 @@
<?php
/**
* @package MokoSuiteCross
* @subpackage com_mokosuitecross
* @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\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Joomla\Component\MokoSuiteCross\Administrator\View\Calendar\HtmlView $this */
$year = $this->year;
$month = $this->month;
$events = $this->events;
$today = date('Y-m-d');
$prevMonth = $month - 1;
$prevYear = $year;
if ($prevMonth < 1) {
$prevMonth = 12;
$prevYear--;
}
$nextMonth = $month + 1;
$nextYear = $year;
if ($nextMonth > 12) {
$nextMonth = 1;
$nextYear++;
}
$monthName = date('F', mktime(0, 0, 0, $month, 1, $year));
$daysInMonth = (int) date('t', mktime(0, 0, 0, $month, 1, $year));
$firstWeekday = ((int) date('N', mktime(0, 0, 0, $month, 1, $year))) - 1;
$statusClass = static function (string $status): string {
return match ($status) {
'posted' => 'bg-success',
'failed' => 'bg-danger',
default => 'bg-warning text-dark',
};
};
?>
<div class="d-flex justify-content-between align-items-center mb-3">
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=calendar&year=' . $prevYear . '&month=' . $prevMonth); ?>"
class="btn btn-outline-secondary btn-sm">
<span class="icon-chevron-left" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_PREV_MONTH'); ?>
</a>
<h3 class="mb-0"><?php echo htmlspecialchars($monthName . ' ' . $year); ?></h3>
<a href="<?php echo Route::_('index.php?option=com_mokosuitecross&view=calendar&year=' . $nextYear . '&month=' . $nextMonth); ?>"
class="btn btn-outline-secondary btn-sm">
<?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_NEXT_MONTH'); ?>
<span class="icon-chevron-right" aria-hidden="true"></span>
</a>
</div>
<div class="table-responsive">
<table class="table table-bordered">
<thead class="table-light">
<tr>
<th style="width:14.28%"><?php echo Text::_('MON'); ?></th>
<th style="width:14.28%"><?php echo Text::_('TUE'); ?></th>
<th style="width:14.28%"><?php echo Text::_('WED'); ?></th>
<th style="width:14.28%"><?php echo Text::_('THU'); ?></th>
<th style="width:14.28%"><?php echo Text::_('FRI'); ?></th>
<th style="width:14.28%"><?php echo Text::_('SAT'); ?></th>
<th style="width:14.28%"><?php echo Text::_('SUN'); ?></th>
</tr>
</thead>
<tbody>
<?php
$day = 1;
$started = false;
while ($day <= $daysInMonth) : ?>
<tr>
<?php for ($col = 0; $col < 7; $col++) :
if (!$started && $col < $firstWeekday) : ?>
<td class="text-muted bg-light">&nbsp;</td>
<?php
continue;
endif;
$started = true;
if ($day > $daysInMonth) : ?>
<td class="text-muted bg-light">&nbsp;</td>
<?php
continue;
endif;
$dateKey = sprintf('%04d-%02d-%02d', $year, $month, $day);
$isToday = ($dateKey === $today);
$cellClass = $isToday ? 'border border-primary border-2 bg-primary bg-opacity-10' : '';
$dayEvents = $events[$dateKey] ?? [];
?>
<td class="<?php echo $cellClass; ?>" style="vertical-align: top; min-height: 80px;">
<div class="fw-bold mb-1<?php echo $isToday ? ' text-primary' : ''; ?>">
<?php echo $day; ?>
<?php if ($isToday) : ?>
<small class="text-primary"><?php echo Text::_('COM_MOKOSUITECROSS_CALENDAR_TODAY'); ?></small>
<?php endif; ?>
</div>
<?php foreach ($dayEvents as $event) : ?>
<span class="badge <?php echo $statusClass($event->status); ?> mb-1 d-block text-truncate" style="max-width: 100%;"
title="<?php echo htmlspecialchars(ucfirst($event->service_type) . ': ' . $event->article_title . ' (' . $event->status . ')'); ?>">
<?php echo htmlspecialchars(ucfirst($event->service_type)); ?>:
<?php echo htmlspecialchars(mb_substr($event->article_title, 0, 20)); ?>
</span>
<?php endforeach; ?>
</td>
<?php
$day++;
endfor; ?>
</tr>
<?php endwhile; ?>
</tbody>
</table>
</div>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteCross</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - ActivityPub (Fediverse)</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Google Blogger</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Bluesky</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Brevo (Sendinblue)</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Constant Contact</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - ConvertKit</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Dev.to</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Discord</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Facebook / Meta</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Ghost</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Google Business Profile</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Google Chat</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Hashnode</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Instagram</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - LinkedIn</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Mailchimp</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Mastodon</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Matrix / Element</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Medium</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - MokoSuiteCalendar Events</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - MokoSuiteGallery</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Nostr</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Ntfy Push Notifications</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Pinterest</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Reddit</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - RSS Feed</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - SendGrid</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Slack</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Microsoft Teams</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Telegram</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Threads (Meta)</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - TikTok</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Tumblr</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - X / Twitter</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Generic Webhook</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - WhatsApp Business</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - WordPress</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="mokosuitecross" method="upgrade">
<name>MokoSuiteCross - Youtube</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteCross</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteCross Events</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteCross Gallery</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteCross Queue Processor</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteCross</name>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -2,7 +2,7 @@
<extension type="package" method="upgrade">
<name>MokoSuiteCross</name>
<packagename>mokosuitecross</packagename>
<version>01.08.53</version>
<version>01.08.56</version>
<creationDate>2026-05-28</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>