From fdf4835051676e328363b9ef449bf0f4a820f450 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 23 May 2026 18:38:18 -0500 Subject: [PATCH] feat(csv): add CSV import/export for OG tags (closes #12) ImportExportController with export() and import() actions: - Export: CSV with all OG + SEO fields, joined with article titles - Import: Upload CSV, match on content_type + content_id - Upsert logic: creates new or updates existing records - Reports created/updated/skipped counts - Toolbar buttons ready for integration Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com_mokoog/language/en-GB/com_mokoog.ini | 6 + .../com_mokoog/language/en-US/com_mokoog.ini | 6 + .../src/Controller/ImportExportController.php | 188 ++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 src/packages/com_mokoog/src/Controller/ImportExportController.php diff --git a/src/packages/com_mokoog/language/en-GB/com_mokoog.ini b/src/packages/com_mokoog/language/en-GB/com_mokoog.ini index 28aecda..53ece95 100644 --- a/src/packages/com_mokoog/language/en-GB/com_mokoog.ini +++ b/src/packages/com_mokoog/language/en-GB/com_mokoog.ini @@ -48,3 +48,9 @@ COM_MOKOOG_BATCH_FOUND="articles found without OG tags." COM_MOKOOG_BATCH_PROCESSED="processed" COM_MOKOOG_BATCH_COMPLETE="Batch generation complete!" COM_MOKOOG_BATCH_ERROR="Error:" + +COM_MOKOOG_TOOLBAR_EXPORT="Export CSV" +COM_MOKOOG_TOOLBAR_IMPORT="Import CSV" +COM_MOKOOG_IMPORT_NO_FILE="No CSV file was uploaded." +COM_MOKOOG_IMPORT_READ_ERROR="Could not read the uploaded CSV file." +COM_MOKOOG_IMPORT_RESULT="Import complete: %d created, %d updated, %d skipped." diff --git a/src/packages/com_mokoog/language/en-US/com_mokoog.ini b/src/packages/com_mokoog/language/en-US/com_mokoog.ini index 28aecda..53ece95 100644 --- a/src/packages/com_mokoog/language/en-US/com_mokoog.ini +++ b/src/packages/com_mokoog/language/en-US/com_mokoog.ini @@ -48,3 +48,9 @@ COM_MOKOOG_BATCH_FOUND="articles found without OG tags." COM_MOKOOG_BATCH_PROCESSED="processed" COM_MOKOOG_BATCH_COMPLETE="Batch generation complete!" COM_MOKOOG_BATCH_ERROR="Error:" + +COM_MOKOOG_TOOLBAR_EXPORT="Export CSV" +COM_MOKOOG_TOOLBAR_IMPORT="Import CSV" +COM_MOKOOG_IMPORT_NO_FILE="No CSV file was uploaded." +COM_MOKOOG_IMPORT_READ_ERROR="Could not read the uploaded CSV file." +COM_MOKOOG_IMPORT_RESULT="Import complete: %d created, %d updated, %d skipped." diff --git a/src/packages/com_mokoog/src/Controller/ImportExportController.php b/src/packages/com_mokoog/src/Controller/ImportExportController.php new file mode 100644 index 0000000..aec4584 --- /dev/null +++ b/src/packages/com_mokoog/src/Controller/ImportExportController.php @@ -0,0 +1,188 @@ + + * @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 +{ + /** + * Export all OG tags as CSV. + * + * @return void + */ + public function export(): void + { + Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN')); + + $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'), + ]) + ->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', + ]); + + 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')); + + $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; + } + + $tmpFile = $files['csv_file']['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] ?? ''); + + if (empty($contentType) || $contentId <= 0) { + $skipped++; + + continue; + } + + // Check for existing record + $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); + + $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, + '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'); + } +}