diff --git a/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini b/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini index eb318bd0..45830408 100644 --- a/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini +++ b/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini @@ -570,65 +570,22 @@ COM_MOKOSUITECROSS_AI_GENERATE_DESC="Generate platform-optimized captions from t COM_MOKOSUITECROSS_AI_GENERATING="Generating captions..." COM_MOKOSUITECROSS_AI_GENERATED="AI captions generated successfully." COM_MOKOSUITECROSS_AI_ERROR="AI generation failed: %s" +COM_MOKOSUITECROSS_AI_NOT_CONFIGURED="AI is not configured. Go to Options to set up a provider and API key." -; Social Image Generator -COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE="Social Image Generator" -COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_ENABLED="Enable Social Images" -COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_ENABLED_DESC="Generate branded OG images with article title overlay for social sharing." -COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR="Background Color" -COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_BG_COLOR_DESC="Hex color for the image background (e.g. #1a1a2e)." -COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR="Text Color" -COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_TEXT_COLOR_DESC="Hex color for the title text overlay." -COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_FONT_SIZE="Font Size" -COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_FONT_SIZE_DESC="Font size in pixels for the title text (24-96)." -COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SHOW_SITE_NAME="Show Site Name" -COM_MOKOSUITECROSS_CONFIG_SOCIAL_IMAGE_SHOW_SITE_NAME_DESC="Display the site name in the bottom-right corner of generated images." -COM_MOKOSUITECROSS_SOCIAL_IMAGE_GENERATE="Generate Social Image" -COM_MOKOSUITECROSS_SOCIAL_IMAGE_GENERATING="Generating image..." -COM_MOKOSUITECROSS_SOCIAL_IMAGE_GENERATED="Social image generated." -COM_MOKOSUITECROSS_SOCIAL_IMAGE_ERROR="Image generation failed: %s" -COM_MOKOSUITECROSS_SOCIAL_IMAGE_NOT_CONFIGURED="Social image generator is not enabled. Go to Options to enable it." - -; 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" - - -; Analytics -COM_MOKOSUITECROSS_ANALYTICS="Analytics" -COM_MOKOSUITECROSS_ANALYTICS_BEST_TIMES="Best Times to Post" -COM_MOKOSUITECROSS_ANALYTICS_HEATMAP="Engagement Heatmap" -COM_MOKOSUITECROSS_ANALYTICS_NO_DATA="Not enough data yet. Analytics will appear after posts collect engagement metrics." -COM_MOKOSUITECROSS_ANALYTICS_ENGAGEMENT_RATE="Engagement Rate" -COM_MOKOSUITECROSS_ANALYTICS_ALL_PLATFORMS="All Platforms" ; 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." -; Calendar View -COM_MOKOSUITECROSS_CALENDAR_PREV_MONTH="Previous" -COM_MOKOSUITECROSS_CALENDAR_NEXT_MONTH="Next" +; Post Calendar +COM_MOKOSUITECROSS_CALENDAR="Post Calendar" +COM_MOKOSUITECROSS_CALENDAR_DESC="Visual calendar of scheduled and posted content" +COM_MOKOSUITECROSS_SUBMENU_CALENDAR="Calendar" COM_MOKOSUITECROSS_CALENDAR_TODAY="Today" -COM_MOKOSUITECROSS_SUBMENU_CALENDAR="Post Calendar" +COM_MOKOSUITECROSS_CALENDAR_MONTH="Month" +COM_MOKOSUITECROSS_CALENDAR_WEEK="Week" +COM_MOKOSUITECROSS_CALENDAR_LIST="List" +COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_SUCCESS="Post rescheduled successfully" +COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR="Failed to reschedule post" +COM_MOKOSUITECROSS_CALENDAR_CANNOT_RESCHEDULE="Only scheduled or queued posts can be rescheduled" +COM_MOKOSUITECROSS_CALENDAR_LOAD_ERROR="Failed to load calendar events" diff --git a/source/packages/com_mokosuitecross/src/Controller/CalendarController.php b/source/packages/com_mokosuitecross/src/Controller/CalendarController.php index e36b117e..3e88763e 100644 --- a/source/packages/com_mokosuitecross/src/Controller/CalendarController.php +++ b/source/packages/com_mokosuitecross/src/Controller/CalendarController.php @@ -13,12 +13,256 @@ namespace Joomla\Component\MokoSuiteCross\Administrator\Controller; defined('_JEXEC') or die; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Session\Session; +use Joomla\Component\MokoSuiteCross\Administrator\Table\PostTable; +/** + * Calendar controller -- provides AJAX endpoints for the visual post calendar. + * + * Endpoints: + * task=calendar.events -- GET JSON feed for FullCalendar (filtered by start/end) + * task=calendar.reschedule -- POST reschedule a post to a new date/time + */ class CalendarController extends BaseController { - public function display($cachable = false, $urlparams = []): static + /** + * Return posts as FullCalendar-compatible JSON events. + * + * Query params: start, end (ISO 8601 date range from FullCalendar). + * + * @return void + */ + public function events(): void { - return parent::display($cachable, $urlparams); + $app = $this->app; + $db = Factory::getDbo(); + + // ACL check + if (!$app->getIdentity()->authorise('core.manage', 'com_mokosuitecross')) { + $this->sendJsonResponse(['error' => 'Forbidden'], 403); + + return; + } + + // FullCalendar sends start/end as ISO date strings + $start = $this->input->getString('start', ''); + $end = $this->input->getString('end', ''); + + $query = $db->getQuery(true) + ->select([ + 'p.' . $db->quoteName('id'), + 'p.' . $db->quoteName('article_id'), + 'p.' . $db->quoteName('service_id'), + 'p.' . $db->quoteName('status'), + 'p.' . $db->quoteName('scheduled_at'), + 'p.' . $db->quoteName('posted_at'), + 'p.' . $db->quoteName('created'), + 'p.' . $db->quoteName('message'), + 'a.' . $db->quoteName('title', 'article_title'), + 's.' . $db->quoteName('title', 'service_title'), + 's.' . $db->quoteName('service_type'), + ]) + ->from($db->quoteName('#__mokosuitecross_posts', 'p')) + ->leftJoin( + $db->quoteName('#__content', 'a') + . ' ON ' . $db->quoteName('a.id') . ' = ' . $db->quoteName('p.article_id') + ) + ->leftJoin( + $db->quoteName('#__mokosuitecross_services', 's') + . ' ON ' . $db->quoteName('s.id') . ' = ' . $db->quoteName('p.service_id') + ) + ->order($db->quoteName('p.created') . ' DESC'); + + // Filter by date range when provided + if ($start !== '') { + $dateExpr = 'COALESCE(p.scheduled_at, p.posted_at, p.created)'; + $query->where($dateExpr . ' >= ' . $db->quote($start)); + } + + if ($end !== '') { + $dateExpr = 'COALESCE(p.scheduled_at, p.posted_at, p.created)'; + $query->where($dateExpr . ' <= ' . $db->quote($end)); + } + + $db->setQuery($query); + $rows = $db->loadObjectList() ?: []; + + // Map status to colour + $statusColors = [ + 'posted' => '#28a745', + 'scheduled' => '#007bff', + 'queued' => '#ffc107', + 'failed' => '#dc3545', + 'posting' => '#17a2b8', + ]; + + $events = []; + + foreach ($rows as $row) { + // Pick the best date for the calendar event + $eventDate = $row->scheduled_at ?: ($row->posted_at ?: $row->created); + + // Skip rows with no usable date + if (empty($eventDate) || $eventDate === '0000-00-00 00:00:00') { + continue; + } + + $title = ($row->article_title ?: 'Post #' . $row->id); + + if ($row->service_title) { + $title .= ' - ' . $row->service_title; + } + + $events[] = [ + 'id' => (int) $row->id, + 'title' => $title, + 'start' => $eventDate, + 'color' => $statusColors[$row->status] ?? '#6c757d', + 'url' => 'index.php?option=com_mokosuitecross&task=post.edit&id=' . (int) $row->id, + 'extendedProps' => [ + 'status' => $row->status, + 'service_type' => $row->service_type ?? '', + 'article_id' => (int) $row->article_id, + 'service_id' => (int) $row->service_id, + 'message' => mb_substr($row->message ?? '', 0, 200), + ], + ]; + } + + $this->sendJsonResponse($events, 200); + } + + /** + * Reschedule a post to a new date/time via drag-drop. + * + * POST params: post_id (int), new_date (ISO 8601 datetime string). + * + * @return void + */ + public function reschedule(): void + { + $app = $this->app; + + // CSRF check + if (!Session::checkToken('post')) { + $this->sendJsonResponse(['error' => Text::_('JINVALID_TOKEN')], 403); + + return; + } + + // ACL check + if (!$app->getIdentity()->authorise('core.edit', 'com_mokosuitecross')) { + $this->sendJsonResponse(['error' => 'Forbidden'], 403); + + return; + } + + $postId = $this->input->getInt('post_id', 0); + $newDate = $this->input->getString('new_date', ''); + + if ($postId < 1 || $newDate === '') { + $this->sendJsonResponse( + ['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')], + 400 + ); + + return; + } + + // Validate the date format + try { + $dateObj = Factory::getDate($newDate); + } catch (\Exception $e) { + $this->sendJsonResponse( + ['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')], + 400 + ); + + return; + } + + // Load the post using Table bind/check/store pattern + $db = Factory::getDbo(); + $table = new PostTable($db); + + if (!$table->load($postId)) { + $this->sendJsonResponse( + ['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')], + 404 + ); + + return; + } + + // Only allow rescheduling of scheduled or queued posts + $allowedStatuses = ['scheduled', 'queued']; + + if (!in_array($table->status, $allowedStatuses, true)) { + $this->sendJsonResponse( + ['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')], + 400 + ); + + return; + } + + // Update the post + $data = [ + 'scheduled_at' => $dateObj->toSql(), + 'status' => 'scheduled', + 'modified' => Factory::getDate()->toSql(), + ]; + + if (!$table->bind($data) || !$table->check() || !$table->store()) { + $this->sendJsonResponse( + ['error' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_ERROR')], + 500 + ); + + return; + } + + // Log the reschedule + $log = (object) [ + 'post_id' => $postId, + 'service_id' => (int) $table->service_id, + 'level' => 'info', + 'message' => sprintf('Post rescheduled to %s via calendar drag-drop', $dateObj->toSql()), + 'context' => '{}', + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitecross_logs', $log); + + $this->sendJsonResponse( + [ + 'success' => true, + 'message' => Text::_('COM_MOKOSUITECROSS_CALENDAR_RESCHEDULE_SUCCESS'), + ], + 200 + ); + } + + /** + * Send a JSON response and close the application. + * + * @param array $data Response data + * @param int $httpCode HTTP status code + * + * @return void + */ + private function sendJsonResponse(array $data, int $httpCode): void + { + $app = $this->app; + + $app->setHeader('Content-Type', 'application/json; charset=utf-8'); + $app->setHeader('Status', (string) $httpCode); + + echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $app->close(); } } diff --git a/source/packages/com_mokosuitecross/src/Helper/MokoSuiteCrossHelper.php b/source/packages/com_mokosuitecross/src/Helper/MokoSuiteCrossHelper.php index 84b66d4d..e9bce359 100644 --- a/source/packages/com_mokosuitecross/src/Helper/MokoSuiteCrossHelper.php +++ b/source/packages/com_mokosuitecross/src/Helper/MokoSuiteCrossHelper.php @@ -40,6 +40,7 @@ class MokoSuiteCrossHelper 'posts' => 'COM_MOKOSUITECROSS_SUBMENU_POSTS', 'services' => 'COM_MOKOSUITECROSS_SUBMENU_SERVICES', 'templates' => 'COM_MOKOSUITECROSS_SUBMENU_TEMPLATES', + 'calendar' => 'COM_MOKOSUITECROSS_SUBMENU_CALENDAR', 'logs' => 'COM_MOKOSUITECROSS_SUBMENU_LOGS', 'calendar' => 'COM_MOKOSUITECROSS_SUBMENU_CALENDAR', 'analytics' => 'COM_MOKOSUITECROSS_SUBMENU_ANALYTICS', diff --git a/source/packages/com_mokosuitecross/src/View/Calendar/HtmlView.php b/source/packages/com_mokosuitecross/src/View/Calendar/HtmlView.php index 58706228..caacba03 100644 --- a/source/packages/com_mokosuitecross/src/View/Calendar/HtmlView.php +++ b/source/packages/com_mokosuitecross/src/View/Calendar/HtmlView.php @@ -14,52 +14,48 @@ namespace Joomla\Component\MokoSuiteCross\Administrator\View\Calendar; defined('_JEXEC') or die; use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Router\Route; 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 $ajaxUrl; public function display($tpl = null): void { - $input = Factory::getApplication()->input; + // ACL check + $canDo = MokoSuiteCrossHelper::getActions(); - $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 (!$canDo->get('core.manage')) { + throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); } - if ($this->year < 2000 || $this->year > 2100) { - $this->year = (int) date('Y'); - } - - $model = $this->getModel(); - $this->events = $model->getEvents($this->year, $this->month); + // Build AJAX URL for FullCalendar event source + $this->ajaxUrl = Route::_('index.php?option=com_mokosuitecross&task=calendar.events&format=json', false); $this->addToolbar(); MokoSuiteCrossHelper::addSubmenu('calendar'); $this->sidebar = \Joomla\CMS\HTML\Sidebar::render(); + // Set document title + Factory::getApplication()->getDocument()->setTitle( + Text::_('COM_MOKOSUITECROSS_CALENDAR') . ' - ' . Text::_('COM_MOKOSUITECROSS') + ); + 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'); - } + ToolbarHelper::title( + Text::_('COM_MOKOSUITECROSS') . ' - ' . Text::_('COM_MOKOSUITECROSS_CALENDAR'), + 'calendar' + ); + ToolbarHelper::back('JTOOLBAR_BACK', Route::_('index.php?option=com_mokosuitecross&view=dashboard', false)); } } diff --git a/source/packages/com_mokosuitecross/tmpl/calendar/default.php b/source/packages/com_mokosuitecross/tmpl/calendar/default.php index dc0e2187..240484fb 100644 --- a/source/packages/com_mokosuitecross/tmpl/calendar/default.php +++ b/source/packages/com_mokosuitecross/tmpl/calendar/default.php @@ -12,118 +12,150 @@ defined('_JEXEC') or die; use Joomla\CMS\Language\Text; -use Joomla\CMS\Router\Route; +use Joomla\CMS\Session\Session; /** @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', - }; -}; +$token = Session::getFormToken(); +$ajaxUrl = $this->ajaxUrl; ?> -
- - - - -

- - - - + + +
+ + + +
-
- - - - - - - - - - - - - - - while ($day <= $daysInMonth) : ?> - - - - + diff --git a/source/packages/com_mokosuitecross/tmpl/dashboard/default.php b/source/packages/com_mokosuitecross/tmpl/dashboard/default.php index 42eb5ff5..b5289951 100644 --- a/source/packages/com_mokosuitecross/tmpl/dashboard/default.php +++ b/source/packages/com_mokosuitecross/tmpl/dashboard/default.php @@ -220,175 +220,6 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler'); - -
-
-
- -
-
-
-

- -
 
- - - - - - - - - - - - - - - - - - -
-
- : - - - - - -
-
- -
- - -
@@ -451,9 +282,9 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler'); class="list-group-item list-group-item-action"> - - +