diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e982e7..36eb8628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] ### Added +- **Visual post calendar**: FullCalendar-powered admin view with month/week/list modes (#160) +- **Post calendar**: color-coded events by status (posted/scheduled/queued/failed) +- **Post calendar**: drag-drop rescheduling with automatic status update - **AI caption generation**: Generate platform-optimized cross-post captions from article content using Claude or OpenAI (#161) - **AI provider config**: New "AI Caption Generation" fieldset in component options with provider, API key, model, and tone settings - **AI Generate button**: One-click AI generation button in the Share Content panel that fills all caption fields 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 09afab49..45830408 100644 --- a/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini +++ b/source/packages/com_mokosuitecross/language/en-GB/com_mokosuitecross.ini @@ -576,3 +576,16 @@ COM_MOKOSUITECROSS_AI_NOT_CONFIGURED="AI is not configured. Go to Options to set 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." + +; 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_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 new file mode 100644 index 00000000..3e88763e --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Controller/CalendarController.php @@ -0,0 +1,268 @@ + + * @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\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 +{ + /** + * Return posts as FullCalendar-compatible JSON events. + * + * Query params: start, end (ISO 8601 date range from FullCalendar). + * + * @return void + */ + public function events(): void + { + $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 04a29037..fe8b6157 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', ]; diff --git a/source/packages/com_mokosuitecross/src/View/Calendar/HtmlView.php b/source/packages/com_mokosuitecross/src/View/Calendar/HtmlView.php new file mode 100644 index 00000000..caacba03 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/View/Calendar/HtmlView.php @@ -0,0 +1,61 @@ + + * @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\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 $sidebar; + public $ajaxUrl; + + public function display($tpl = null): void + { + // ACL check + $canDo = MokoSuiteCrossHelper::getActions(); + + if (!$canDo->get('core.manage')) { + throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + // 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 + { + 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 new file mode 100644 index 00000000..240484fb --- /dev/null +++ b/source/packages/com_mokosuitecross/tmpl/calendar/default.php @@ -0,0 +1,161 @@ + + * @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\Session\Session; + +/** @var \Joomla\Component\MokoSuiteCross\Administrator\View\Calendar\HtmlView $this */ + +$token = Session::getFormToken(); +$ajaxUrl = $this->ajaxUrl; +?> + + + +
+ + + + +
+ +
+ + + diff --git a/source/packages/com_mokosuitecross/tmpl/dashboard/default.php b/source/packages/com_mokosuitecross/tmpl/dashboard/default.php index 13be5781..b5289951 100644 --- a/source/packages/com_mokosuitecross/tmpl/dashboard/default.php +++ b/source/packages/com_mokosuitecross/tmpl/dashboard/default.php @@ -282,6 +282,10 @@ $queueProcessing = $componentParams->get('queue_processing', 'scheduler'); class="list-group-item list-group-item-action"> + + +