MokoOpenGraph component installed successfully.
'; + echo 'MokoJoomOpenGraph component installed successfully.
'; } /** @@ -35,6 +35,6 @@ class Com_MokoOGInstallerScript */ public function update(InstallerAdapter $parent): void { - echo 'MokoOpenGraph component updated successfully.
'; + echo 'MokoJoomOpenGraph component updated successfully.
'; } } diff --git a/source/packages/com_mokoog/services/index.html b/source/packages/com_mokoog/services/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokoog/services/provider.php b/source/packages/com_mokoog/services/provider.php similarity index 97% rename from src/packages/com_mokoog/services/provider.php rename to source/packages/com_mokoog/services/provider.php index 249c8a1..27c3354 100644 --- a/src/packages/com_mokoog/services/provider.php +++ b/source/packages/com_mokoog/services/provider.php @@ -1,7 +1,7 @@ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/sql/index.html b/source/packages/com_mokoog/sql/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/sql/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokoog/sql/install.mysql.sql b/source/packages/com_mokoog/sql/install.mysql.sql similarity index 69% rename from src/packages/com_mokoog/sql/install.mysql.sql rename to source/packages/com_mokoog/sql/install.mysql.sql index 884d369..cc3558a 100644 --- a/src/packages/com_mokoog/sql/install.mysql.sql +++ b/source/packages/com_mokoog/sql/install.mysql.sql @@ -1,5 +1,5 @@ -- --- MokoOpenGraph - Database Schema +-- MokoJoomOpenGraph - Database Schema -- Copyright (C) 2026 Moko Consulting. All rights reserved. -- License: GPL-3.0-or-later -- @@ -12,10 +12,15 @@ CREATE TABLE IF NOT EXISTS `#__mokoog_tags` ( `og_description` TEXT NOT NULL, `og_image` VARCHAR(512) NOT NULL DEFAULT '', `og_type` VARCHAR(50) NOT NULL DEFAULT 'article', + `seo_title` VARCHAR(70) NOT NULL DEFAULT '', + `meta_description` VARCHAR(200) NOT NULL DEFAULT '', + `robots` VARCHAR(100) NOT NULL DEFAULT '', + `canonical_url` VARCHAR(512) NOT NULL DEFAULT '', + `language` CHAR(7) NOT NULL DEFAULT '*', `published` TINYINT(1) NOT NULL DEFAULT 1, `created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', `modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (`id`), - UNIQUE KEY `idx_content` (`content_type`, `content_id`), + UNIQUE KEY `idx_content_lang` (`content_type`, `content_id`, `language`), KEY `idx_published` (`published`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/packages/com_mokoog/sql/uninstall.mysql.sql b/source/packages/com_mokoog/sql/uninstall.mysql.sql similarity index 58% rename from src/packages/com_mokoog/sql/uninstall.mysql.sql rename to source/packages/com_mokoog/sql/uninstall.mysql.sql index ab50f15..8652ac6 100644 --- a/src/packages/com_mokoog/sql/uninstall.mysql.sql +++ b/source/packages/com_mokoog/sql/uninstall.mysql.sql @@ -1,5 +1,5 @@ -- --- MokoOpenGraph - Uninstall +-- MokoJoomOpenGraph - Uninstall -- DROP TABLE IF EXISTS `#__mokoog_tags`; diff --git a/source/packages/com_mokoog/sql/updates/index.html b/source/packages/com_mokoog/sql/updates/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokoog/sql/updates/mysql/01.00.00.sql b/source/packages/com_mokoog/sql/updates/mysql/01.00.00.sql similarity index 100% rename from src/packages/com_mokoog/sql/updates/mysql/01.00.00.sql rename to source/packages/com_mokoog/sql/updates/mysql/01.00.00.sql diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.01.00.sql b/source/packages/com_mokoog/sql/updates/mysql/01.01.00.sql new file mode 100644 index 0000000..5bac763 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.01.00.sql @@ -0,0 +1,9 @@ +-- +-- MokoJoomOpenGraph 01.01.00 — Add SEO meta management columns +-- + +ALTER TABLE `#__mokoog_tags` + ADD COLUMN `seo_title` VARCHAR(70) NOT NULL DEFAULT '' AFTER `og_type`, + ADD COLUMN `meta_description` VARCHAR(200) NOT NULL DEFAULT '' AFTER `seo_title`, + ADD COLUMN `robots` VARCHAR(100) NOT NULL DEFAULT '' AFTER `meta_description`, + ADD COLUMN `canonical_url` VARCHAR(512) NOT NULL DEFAULT '' AFTER `robots`; diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.02.00.sql b/source/packages/com_mokoog/sql/updates/mysql/01.02.00.sql new file mode 100644 index 0000000..ca87208 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.02.00.sql @@ -0,0 +1,10 @@ +-- +-- MokoJoomOpenGraph 01.02.00 — Add multilingual OG tag support +-- + +ALTER TABLE `#__mokoog_tags` + ADD COLUMN `language` CHAR(7) NOT NULL DEFAULT '*' AFTER `canonical_url`; + +ALTER TABLE `#__mokoog_tags` + DROP INDEX `idx_content`, + ADD UNIQUE KEY `idx_content_lang` (`content_type`, `content_id`, `language`); diff --git a/source/packages/com_mokoog/sql/updates/mysql/index.html b/source/packages/com_mokoog/sql/updates/mysql/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/com_mokoog/src/ContentType/index.html b/source/packages/com_mokoog/src/ContentType/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/ContentType/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/com_mokoog/src/Controller/BatchController.php b/source/packages/com_mokoog/src/Controller/BatchController.php new file mode 100644 index 0000000..68141f4 --- /dev/null +++ b/source/packages/com_mokoog/src/Controller/BatchController.php @@ -0,0 +1,182 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Response\JsonResponse; +use Joomla\CMS\Session\Session; + +class BatchController extends BaseController +{ + /** + * Count the total articles eligible for batch generation. + * + * @return void + */ + public function count(): void + { + Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN')); + + if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokoog')) { + throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403); + } + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__content', 'c')) + ->leftJoin( + $db->quoteName('#__mokoog_tags', 't') + . ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content') + . ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id') + ) + ->where($db->quoteName('c.state') . ' = 1') + ->where($db->quoteName('t.id') . ' IS NULL'); + + $db->setQuery($query); + $total = (int) $db->loadResult(); + + echo new JsonResponse(['total' => $total]); + + Factory::getApplication()->close(); + } + + /** + * Process a chunk of articles for batch OG generation. + * + * @return void + */ + public function process(): void + { + Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN')); + + if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokoog')) { + throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403); + } + + $app = Factory::getApplication(); + $limit = min($app->getInput()->getInt('limit', 50), 200); + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName([ + 'c.id', 'c.title', 'c.metadesc', 'c.introtext', 'c.fulltext', 'c.images', + ])) + ->from($db->quoteName('#__content', 'c')) + ->leftJoin( + $db->quoteName('#__mokoog_tags', 't') + . ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content') + . ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id') + ) + ->where($db->quoteName('c.state') . ' = 1') + ->where($db->quoteName('t.id') . ' IS NULL') + ->order($db->quoteName('c.id') . ' ASC'); + + // Always offset=0: processed articles now have #__mokoog_tags rows + // and are excluded by the LEFT JOIN ... IS NULL filter automatically. + $db->setQuery($query, 0, $limit); + $articles = $db->loadObjectList(); + + $created = 0; + $skipped = 0; + $now = Factory::getDate()->toSql(); + + foreach ($articles as $article) { + $ogTitle = $article->title; + $ogDescription = $this->extractDescription($article); + $ogImage = $this->extractImage($article); + + $record = (object) [ + 'content_type' => 'com_content', + 'content_id' => (int) $article->id, + 'og_title' => $ogTitle, + 'og_description' => $ogDescription, + 'og_image' => $ogImage, + 'og_type' => 'article', + 'seo_title' => '', + 'meta_description' => $article->metadesc ?: '', + 'robots' => '', + 'canonical_url' => '', + 'language' => '*', + 'published' => 1, + 'created' => $now, + 'modified' => $now, + ]; + + try { + $db->insertObject('#__mokoog_tags', $record); + $created++; + } catch (\RuntimeException $e) { + $skipped++; + } + } + + echo new JsonResponse([ + 'created' => $created, + ]); + + $app->close(); + } + + /** + * Extract a description from article content. + * + * @param object $article Article record + * + * @return string + */ + private function extractDescription(object $article): string + { + // Prefer meta description if set + if (!empty($article->metadesc)) { + return $article->metadesc; + } + + // Fall back to intro text + $text = $article->introtext ?: $article->fulltext; + $text = strip_tags($text); + $text = trim(preg_replace('/\s+/', ' ', $text)); + + if (mb_strlen($text) > 160) { + $text = mb_substr($text, 0, 157) . '...'; + } + + return $text; + } + + /** + * Extract the best image from article data. + * + * @param object $article Article record + * + * @return string + */ + private function extractImage(object $article): string + { + if (!empty($article->images)) { + $images = json_decode($article->images, true); + + if (!empty($images['image_fulltext'])) { + return $images['image_fulltext']; + } + + if (!empty($images['image_intro'])) { + return $images['image_intro']; + } + } + + return ''; + } +} diff --git a/src/packages/com_mokoog/src/Controller/DisplayController.php b/source/packages/com_mokoog/src/Controller/DisplayController.php similarity index 94% rename from src/packages/com_mokoog/src/Controller/DisplayController.php rename to source/packages/com_mokoog/src/Controller/DisplayController.php index d65ffaf..28b73c9 100644 --- a/src/packages/com_mokoog/src/Controller/DisplayController.php +++ b/source/packages/com_mokoog/src/Controller/DisplayController.php @@ -1,7 +1,7 @@ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/src/Controller/ImportExportController.php b/source/packages/com_mokoog/src/Controller/ImportExportController.php new file mode 100644 index 0000000..5fcb49f --- /dev/null +++ b/source/packages/com_mokoog/src/Controller/ImportExportController.php @@ -0,0 +1,255 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\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; + +class ImportExportController extends BaseController +{ + /** + * Maximum upload file size in bytes (2 MB). + */ + private const MAX_FILE_SIZE = 2 * 1024 * 1024; + + /** + * Allowed content_type patterns for import. + */ + private const CONTENT_TYPE_PATTERN = '/^[a-z][a-z0-9_.]*$/'; + + /** + * Export all OG tags as CSV. + * + * @return void + */ + public function export(): void + { + Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN')); + + if (!Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_mokoog')) { + throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403); + } + + $app = Factory::getApplication(); + $db = Factory::getDbo(); + + // Join with #__content to get article titles for reference + $query = $db->getQuery(true) + ->select([ + $db->quoteName('t.content_type'), + $db->quoteName('t.content_id'), + 'COALESCE(' . $db->quoteName('c.title') . ', ' . $db->quote('') . ') AS ' . $db->quoteName('article_title'), + $db->quoteName('t.og_title'), + $db->quoteName('t.og_description'), + $db->quoteName('t.og_image'), + $db->quoteName('t.og_type'), + $db->quoteName('t.seo_title'), + $db->quoteName('t.meta_description'), + $db->quoteName('t.robots'), + $db->quoteName('t.canonical_url'), + $db->quoteName('t.language'), + ]) + ->from($db->quoteName('#__mokoog_tags', 't')) + ->leftJoin( + $db->quoteName('#__content', 'c') + . ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content') + . ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id') + ) + ->order($db->quoteName('t.content_type') . ', ' . $db->quoteName('t.content_id')); + + $db->setQuery($query); + $rows = $db->loadAssocList(); + + // Send CSV headers + $app->setHeader('Content-Type', 'text/csv; charset=utf-8'); + $app->setHeader('Content-Disposition', 'attachment; filename="mokoog_tags_export.csv"'); + $app->sendHeaders(); + + $output = fopen('php://output', 'w'); + + // Header row + fputcsv($output, [ + 'content_type', 'content_id', 'article_title', + 'og_title', 'og_description', 'og_image', 'og_type', + 'seo_title', 'meta_description', 'robots', 'canonical_url', + 'language', + ]); + + foreach ($rows as $row) { + fputcsv($output, $row); + } + + fclose($output); + $app->close(); + } + + /** + * Import OG tags from uploaded CSV. + * + * @return void + */ + public function import(): void + { + Session::checkToken() || jexit(Text::_('JINVALID_TOKEN')); + + $identity = Factory::getApplication()->getIdentity(); + + if (!$identity->authorise('core.create', 'com_mokoog') || !$identity->authorise('core.edit', 'com_mokoog')) { + throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403); + } + + $app = Factory::getApplication(); + $input = $app->getInput(); + $files = $input->files->get('jform', [], 'array'); + + if (empty($files['csv_file']['tmp_name'])) { + $app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_NO_FILE'), 'error'); + $app->redirect('index.php?option=com_mokoog&view=tags'); + + return; + } + + $csvFile = $files['csv_file']; + + // Validate file extension + $ext = strtolower(pathinfo($csvFile['name'] ?? '', PATHINFO_EXTENSION)); + + if ($ext !== 'csv') { + $app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_INVALID_TYPE'), 'error'); + $app->redirect('index.php?option=com_mokoog&view=tags'); + + return; + } + + // Validate MIME type + $allowedMimes = ['text/csv', 'text/plain', 'application/csv', 'application/vnd.ms-excel']; + + if (!empty($csvFile['type']) && !\in_array($csvFile['type'], $allowedMimes, true)) { + $app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_INVALID_TYPE'), 'error'); + $app->redirect('index.php?option=com_mokoog&view=tags'); + + return; + } + + // Validate file size + if (($csvFile['size'] ?? 0) > self::MAX_FILE_SIZE) { + $app->enqueueMessage(Text::sprintf('COM_MOKOOG_IMPORT_FILE_TOO_LARGE', '2 MB'), 'error'); + $app->redirect('index.php?option=com_mokoog&view=tags'); + + return; + } + + $tmpFile = $csvFile['tmp_name']; + $handle = fopen($tmpFile, 'r'); + + if (!$handle) { + $app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_READ_ERROR'), 'error'); + $app->redirect('index.php?option=com_mokoog&view=tags'); + + return; + } + + $db = Factory::getDbo(); + $header = fgetcsv($handle); + $created = 0; + $updated = 0; + $skipped = 0; + $now = Factory::getDate()->toSql(); + + while (($row = fgetcsv($handle)) !== false) { + if (\count($row) < 7) { + $skipped++; + + continue; + } + + $contentType = trim($row[0]); + $contentId = (int) $row[1]; + // $row[2] = article_title (informational, skip) + $ogTitle = trim($row[3] ?? ''); + $ogDescription = trim($row[4] ?? ''); + $ogImage = trim($row[5] ?? ''); + $ogType = trim($row[6] ?? 'article'); + $seoTitle = trim($row[7] ?? ''); + $metaDesc = trim($row[8] ?? ''); + $robots = trim($row[9] ?? ''); + $canonicalUrl = trim($row[10] ?? ''); + $language = trim($row[11] ?? '*'); + + // Validate language tag format (e.g., 'en-GB', '*') + if ($language !== '*' && !preg_match('/^[a-z]{2,3}-[A-Z]{2}$/', $language)) { + $language = '*'; + } + + if (empty($contentType) || $contentId <= 0) { + $skipped++; + + continue; + } + + // Validate content_type against allowed pattern + if (!preg_match(self::CONTENT_TYPE_PATTERN, $contentType)) { + $skipped++; + + continue; + } + + // Check for existing record (unique key includes language) + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__mokoog_tags')) + ->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType)) + ->where($db->quoteName('content_id') . ' = ' . $contentId) + ->where($db->quoteName('language') . ' = ' . $db->quote($language)); + + $db->setQuery($query); + $existingId = $db->loadResult(); + + $record = (object) [ + 'content_type' => $contentType, + 'content_id' => $contentId, + 'og_title' => $ogTitle, + 'og_description' => $ogDescription, + 'og_image' => $ogImage, + 'og_type' => $ogType, + 'seo_title' => $seoTitle, + 'meta_description' => $metaDesc, + 'robots' => $robots, + 'canonical_url' => $canonicalUrl, + 'language' => $language, + 'published' => 1, + 'modified' => $now, + ]; + + if ($existingId) { + $record->id = $existingId; + $db->updateObject('#__mokoog_tags', $record, 'id'); + $updated++; + } else { + $record->created = $now; + $db->insertObject('#__mokoog_tags', $record); + $created++; + } + } + + fclose($handle); + + $app->enqueueMessage( + Text::sprintf('COM_MOKOOG_IMPORT_RESULT', $created, $updated, $skipped), + 'success' + ); + $app->redirect('index.php?option=com_mokoog&view=tags'); + } +} diff --git a/source/packages/com_mokoog/src/Controller/TagsController.php b/source/packages/com_mokoog/src/Controller/TagsController.php new file mode 100644 index 0000000..00d2000 --- /dev/null +++ b/source/packages/com_mokoog/src/Controller/TagsController.php @@ -0,0 +1,33 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Controller\AdminController; +use Joomla\CMS\MVC\Model\BaseDatabaseModel; + +class TagsController extends AdminController +{ + /** + * Proxy for getModel. + * + * @param string $name Model name + * @param string $prefix Model prefix + * @param array $config Configuration array + * + * @return BaseDatabaseModel + */ + public function getModel($name = 'Tag', $prefix = 'Administrator', $config = ['ignore_request' => true]) + { + return parent::getModel($name, $prefix, $config); + } +} diff --git a/source/packages/com_mokoog/src/Controller/index.html b/source/packages/com_mokoog/src/Controller/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/Controller/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokoog/src/Extension/MokoOGComponent.php b/source/packages/com_mokoog/src/Extension/MokoOGComponent.php similarity index 92% rename from src/packages/com_mokoog/src/Extension/MokoOGComponent.php rename to source/packages/com_mokoog/src/Extension/MokoOGComponent.php index 65307b3..f8e3a9e 100644 --- a/src/packages/com_mokoog/src/Extension/MokoOGComponent.php +++ b/source/packages/com_mokoog/src/Extension/MokoOGComponent.php @@ -1,7 +1,7 @@ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/src/Extension/index.html b/source/packages/com_mokoog/src/Extension/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/com_mokoog/src/Field/index.html b/source/packages/com_mokoog/src/Field/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/Field/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/com_mokoog/src/Model/TagModel.php b/source/packages/com_mokoog/src/Model/TagModel.php new file mode 100644 index 0000000..c56b682 --- /dev/null +++ b/source/packages/com_mokoog/src/Model/TagModel.php @@ -0,0 +1,68 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Administrator\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Model\AdminModel; + +class TagModel extends AdminModel +{ + /** + * Get the form for the item. + * + * @param array $data Form data + * @param bool $loadData Load data from state + * + * @return \Joomla\CMS\Form\Form|false + */ + public function getForm($data = [], $loadData = true) + { + $form = $this->loadForm( + 'com_mokoog.tag', + 'tag', + ['control' => 'jform', 'load_data' => $loadData] + ); + + return $form ?: false; + } + + /** + * Load the form data. + * + * @return object + */ + protected function loadFormData(): object + { + $data = Factory::getApplication()->getUserState('com_mokoog.edit.tag.data', []); + + if (empty($data)) { + $data = $this->getItem(); + } + + return $data; + } + + /** + * Get the table class name. + * + * @param string $name Table name + * @param string $prefix Table prefix + * @param array $options Table options + * + * @return \Joomla\CMS\Table\Table + */ + public function getTable($name = 'Tag', $prefix = 'Administrator', $options = []) + { + return parent::getTable($name, $prefix, $options); + } +} diff --git a/src/packages/com_mokoog/src/Model/TagsModel.php b/source/packages/com_mokoog/src/Model/TagsModel.php similarity index 98% rename from src/packages/com_mokoog/src/Model/TagsModel.php rename to source/packages/com_mokoog/src/Model/TagsModel.php index 6294af5..4171c21 100644 --- a/src/packages/com_mokoog/src/Model/TagsModel.php +++ b/source/packages/com_mokoog/src/Model/TagsModel.php @@ -1,7 +1,7 @@ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/src/Model/index.html b/source/packages/com_mokoog/src/Model/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/Model/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/com_mokoog/src/Service/index.html b/source/packages/com_mokoog/src/Service/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/Service/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/com_mokoog/src/Table/TagTable.php b/source/packages/com_mokoog/src/Table/TagTable.php new file mode 100644 index 0000000..af85bac --- /dev/null +++ b/source/packages/com_mokoog/src/Table/TagTable.php @@ -0,0 +1,107 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Administrator\Table; + +defined('_JEXEC') or die; + +use Joomla\CMS\Table\Table; +use Joomla\Database\DatabaseDriver; + +class TagTable extends Table +{ + /** + * Constructor. + * + * @param DatabaseDriver $db Database driver instance + */ + public function __construct(DatabaseDriver $db) + { + parent::__construct('#__mokoog_tags', 'id', $db); + } + + /** + * Perform checks before store. + * + * @return bool + */ + private const VALID_OG_TYPES = [ + 'article', 'website', 'product', 'profile', 'book', 'music.song', + 'music.album', 'video.movie', 'video.episode', 'video.other', + ]; + + private const VALID_ROBOTS = [ + 'index', 'noindex', 'follow', 'nofollow', 'none', 'noarchive', + 'nosnippet', 'noimageindex', 'max-snippet', 'max-image-preview', + ]; + + public function check(): bool + { + if (empty($this->content_type)) { + $this->setError('Content type is required.'); + + return false; + } + + if (!preg_match('/^[a-z][a-z0-9_.]*$/', $this->content_type)) { + $this->setError('Content type contains invalid characters.'); + + return false; + } + + if (empty($this->content_id)) { + $this->setError('Content ID is required.'); + + return false; + } + + // Validate og_type against known values + if (!empty($this->og_type) && !\in_array($this->og_type, self::VALID_OG_TYPES, true)) { + $this->og_type = 'article'; + } + + // Truncate fields to schema max lengths + if (mb_strlen($this->og_title ?? '') > 255) { + $this->og_title = mb_substr($this->og_title, 0, 255); + } + + if (mb_strlen($this->seo_title ?? '') > 70) { + $this->seo_title = mb_substr($this->seo_title, 0, 70); + } + + if (mb_strlen($this->meta_description ?? '') > 200) { + $this->meta_description = mb_substr($this->meta_description, 0, 200); + } + + // Validate canonical_url format if non-empty + if (!empty($this->canonical_url) && !filter_var($this->canonical_url, FILTER_VALIDATE_URL)) { + $this->canonical_url = ''; + } + + // Validate robots directives + if (!empty($this->robots)) { + $parts = array_map('trim', explode(',', strtolower($this->robots))); + $valid = array_filter($parts, function ($part) { + // Allow directives with values like "max-snippet:-1" + $directive = explode(':', $part)[0]; + + return \in_array($directive, self::VALID_ROBOTS, true); + }); + $this->robots = $valid ? implode(', ', $valid) : ''; + } + + // Default language to '*' if not set + if (empty($this->language)) { + $this->language = '*'; + } + + return true; + } +} diff --git a/source/packages/com_mokoog/src/Table/index.html b/source/packages/com_mokoog/src/Table/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/Table/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokoog/src/View/Tags/HtmlView.php b/source/packages/com_mokoog/src/View/Tags/HtmlView.php similarity index 65% rename from src/packages/com_mokoog/src/View/Tags/HtmlView.php rename to source/packages/com_mokoog/src/View/Tags/HtmlView.php index 14ff7cb..eca4100 100644 --- a/src/packages/com_mokoog/src/View/Tags/HtmlView.php +++ b/source/packages/com_mokoog/src/View/Tags/HtmlView.php @@ -1,7 +1,7 @@ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. @@ -39,6 +39,20 @@ class HtmlView extends BaseHtmlView */ protected $state; + /** + * The filter form. + * + * @var \Joomla\CMS\Form\Form|null + */ + public $filterForm; + + /** + * The active filters. + * + * @var array + */ + public $activeFilters = []; + /** * Display the view. * @@ -48,9 +62,11 @@ class HtmlView extends BaseHtmlView */ public function display($tpl = null): void { - $this->items = $this->get('Items'); - $this->pagination = $this->get('Pagination'); - $this->state = $this->get('State'); + $this->items = $this->get('Items'); + $this->pagination = $this->get('Pagination'); + $this->state = $this->get('State'); + $this->filterForm = $this->get('FilterForm'); + $this->activeFilters = $this->get('ActiveFilters'); $this->addToolbar(); @@ -65,6 +81,8 @@ class HtmlView extends BaseHtmlView protected function addToolbar(): void { ToolbarHelper::title(Text::_('COM_MOKOOG_TAGS_TITLE'), 'bookmark'); + ToolbarHelper::custom('batch.generate', 'refresh', '', 'COM_MOKOOG_TOOLBAR_BATCH_GENERATE', false); + ToolbarHelper::custom('importexport.export', 'download', '', 'COM_MOKOOG_TOOLBAR_EXPORT', false); ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'tags.delete'); ToolbarHelper::preferences('com_mokoog'); } diff --git a/source/packages/com_mokoog/src/View/Tags/index.html b/source/packages/com_mokoog/src/View/Tags/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/View/Tags/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/com_mokoog/src/View/Tags/tmpl/index.html b/source/packages/com_mokoog/src/View/Tags/tmpl/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/View/Tags/tmpl/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/com_mokoog/src/View/index.html b/source/packages/com_mokoog/src/View/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/View/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/com_mokoog/src/index.html b/source/packages/com_mokoog/src/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/com_mokoog/tmpl/index.html b/source/packages/com_mokoog/tmpl/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/tmpl/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/com_mokoog/tmpl/tags/default.php b/source/packages/com_mokoog/tmpl/tags/default.php new file mode 100644 index 0000000..6ca4e90 --- /dev/null +++ b/source/packages/com_mokoog/tmpl/tags/default.php @@ -0,0 +1,243 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Layout\LayoutHelper; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Uri\Uri; +use Joomla\CMS\Session\Session; + +/** @var \Joomla\Component\MokoOG\Administrator\View\Tags\HtmlView $this */ + +$token = Session::getFormToken(); +?> + + + + + + diff --git a/source/packages/com_mokoog/tmpl/tags/index.html b/source/packages/com_mokoog/tmpl/tags/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/tmpl/tags/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/index.html b/source/packages/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/index.html @@ -0,0 +1 @@ + diff --git a/source/packages/plg_content_mokoog/forms/index.html b/source/packages/plg_content_mokoog/forms/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_content_mokoog/forms/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_content_mokoog/forms/mokoog.xml b/source/packages/plg_content_mokoog/forms/mokoog.xml similarity index 53% rename from src/packages/plg_content_mokoog/forms/mokoog.xml rename to source/packages/plg_content_mokoog/forms/mokoog.xml index 1e9f026..90be310 100644 --- a/src/packages/plg_content_mokoog/forms/mokoog.xml +++ b/source/packages/plg_content_mokoog/forms/mokoog.xml @@ -1,6 +1,6 @@MOKO-XXXX-XXXX-XXXX-XXXX) in the Download Key field '
+ . 'for the MokoSuiteOpenGraph update site.',
+ 'warning'
+ );
+ } catch (\Throwable $e) {
+ // Don't break admin over a license check, but log for debugging
+ \Joomla\CMS\Log\Log::add('MokoOG license check: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog');
+ }
+ }
+
+ /**
+ * Load MokoSuiteShop product data by product ID.
+ *
+ * @param int $productId CRM product ID
+ *
+ * @return object|null Product with name, description, images, price, currency, sku
+ */
+ private function loadShopProduct(int $productId): ?object
+ {
+ static $cache = [];
+
+ if (isset($cache[$productId])) {
+ return $cache[$productId];
+ }
+
+ try {
+ $db = Factory::getDbo();
+ $query = $db->getQuery(true)
+ ->select('p.id, p.sku, p.price, p.currency, p.stock_qty')
+ ->select('c.title AS name, c.introtext AS description, c.images')
+ ->from($db->quoteName('#__mokosuite_crm_products', 'p'))
+ ->join('LEFT', $db->quoteName('#__content', 'c') . ' ON c.id = p.article_id')
+ ->where($db->quoteName('p.id') . ' = ' . $productId)
+ ->where($db->quoteName('p.published') . ' = 1');
+
+ $db->setQuery($query);
+ $cache[$productId] = $db->loadObject();
+ } catch (\RuntimeException $e) {
+ // MokoSuiteShop tables may not exist
+ $cache[$productId] = null;
+ }
+
+ return $cache[$productId];
+ }
+
+ /**
+ * Get the actual pixel dimensions of a local image.
+ *
+ * @param string $image Image path (relative or absolute URL)
+ *
+ * @return array{0: int, 1: int}|null
+ */
+ private function getImageDimensions(string $image): ?array
+ {
+ // Cannot determine dimensions for external URLs
+ if (str_starts_with($image, 'http://') || str_starts_with($image, 'https://')) {
+ return null;
+ }
+
+ // If auto-resize is on, the resized image lives in the generated dir
+ if ($this->params->get('auto_resize', 1)) {
+ $resolved = ImageHelper::resize($image);
+ } else {
+ $resolved = $image;
+ }
+
+ $absPath = JPATH_ROOT . '/' . ltrim($resolved, '/');
+
+ if (!is_file($absPath)) {
+ return null;
+ }
+
+ $info = getimagesize($absPath);
+
+ if (!$info) {
+ return null;
+ }
+
+ return [$info[0], $info[1]];
+ }
+}
diff --git a/source/packages/plg_system_mokoog/src/Extension/index.html b/source/packages/plg_system_mokoog/src/Extension/index.html
new file mode 100644
index 0000000..94906bc
--- /dev/null
+++ b/source/packages/plg_system_mokoog/src/Extension/index.html
@@ -0,0 +1 @@
+