From 8a3897664e32e0818282ea971508dbcd7a68aecb Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 31 May 2026 12:01:15 -0500 Subject: [PATCH] feat: rewrite content sync to use Joomla REST API with per-task config Each sync task instance now has its own target URL, API token, and content type toggles. Sync strategy is delete-then-push via the Joomla API for articles and menus (avoids duplicates, respects ACL). Content types: articles, categories, menus, modules File types: images/, files/, media/ (via MokoWaaS sync-receive endpoint) Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- .../forms/sync_params.xml | 75 ++- .../language/en-GB/plg_task_mokowaassync.ini | 33 +- .../src/Extension/ContentSync.php | 567 ++++++++++++++++-- 3 files changed, 609 insertions(+), 66 deletions(-) diff --git a/src/packages/plg_task_mokowaassync/forms/sync_params.xml b/src/packages/plg_task_mokowaassync/forms/sync_params.xml index 881e09d..820bdad 100644 --- a/src/packages/plg_task_mokowaassync/forms/sync_params.xml +++ b/src/packages/plg_task_mokowaassync/forms/sync_params.xml @@ -1,8 +1,75 @@
-
- +
+ + + +
+ +
+ + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + +
diff --git a/src/packages/plg_task_mokowaassync/language/en-GB/plg_task_mokowaassync.ini b/src/packages/plg_task_mokowaassync/language/en-GB/plg_task_mokowaassync.ini index 049f286..bb34177 100644 --- a/src/packages/plg_task_mokowaassync/language/en-GB/plg_task_mokowaassync.ini +++ b/src/packages/plg_task_mokowaassync/language/en-GB/plg_task_mokowaassync.ini @@ -3,6 +3,35 @@ ; SPDX-License-Identifier: GPL-3.0-or-later PLG_TASK_MOKOWAASSYNC="Task - MokoWaaS Content Sync" -PLG_TASK_MOKOWAASSYNC_DESC="Scheduled task to push content (articles, categories, menus, modules) to remote MokoWaaS sites." +PLG_TASK_MOKOWAASSYNC_DESC="Scheduled task to sync content to a remote MokoWaaS site via the Joomla API. Each task instance syncs to one target." PLG_TASK_MOKOWAASSYNC_SYNC_TITLE="MokoWaaS Content Sync" -PLG_TASK_MOKOWAASSYNC_SYNC_DESC="Push site content to all configured sync targets. Targets are configured in the MokoWaaS system plugin settings." +PLG_TASK_MOKOWAASSYNC_SYNC_DESC="Sync selected content types to a single remote target site via the Joomla REST API." + +; ===== Target fieldset ===== +PLG_TASK_MOKOWAASSYNC_FIELDSET_TARGET="Sync Target" +PLG_TASK_MOKOWAASSYNC_TARGET_URL_LABEL="Target Site URL" +PLG_TASK_MOKOWAASSYNC_TARGET_URL_DESC="Base URL of the remote Joomla site to sync to (e.g. https://demo.example.com)." +PLG_TASK_MOKOWAASSYNC_API_TOKEN_LABEL="API Token" +PLG_TASK_MOKOWAASSYNC_API_TOKEN_DESC="Joomla API token (Bearer token) for authenticating with the target site's REST API." +PLG_TASK_MOKOWAASSYNC_API_USER_LABEL="API User" +PLG_TASK_MOKOWAASSYNC_API_USER_DESC="Optional username on the target site. Used for logging purposes only." + +; ===== Content types fieldset ===== +PLG_TASK_MOKOWAASSYNC_FIELDSET_CONTENT="Content to Sync" +PLG_TASK_MOKOWAASSYNC_SYNC_ARTICLES_LABEL="Articles" +PLG_TASK_MOKOWAASSYNC_SYNC_ARTICLES_DESC="Sync articles (com_content). Deletes all articles on the target, then pushes exact copies from this site." +PLG_TASK_MOKOWAASSYNC_SYNC_CATEGORIES_LABEL="Categories" +PLG_TASK_MOKOWAASSYNC_SYNC_CATEGORIES_DESC="Sync content categories. Ensures category structure matches this site." +PLG_TASK_MOKOWAASSYNC_SYNC_MENUS_LABEL="Menus" +PLG_TASK_MOKOWAASSYNC_SYNC_MENUS_DESC="Sync menu items. Deletes all menu items on the target, then pushes exact copies from this site." +PLG_TASK_MOKOWAASSYNC_SYNC_MODULES_LABEL="Modules" +PLG_TASK_MOKOWAASSYNC_SYNC_MODULES_DESC="Sync site modules. Pushes module configuration and assignments." + +; ===== Files fieldset ===== +PLG_TASK_MOKOWAASSYNC_FIELDSET_FILES="Files to Sync" +PLG_TASK_MOKOWAASSYNC_SYNC_IMAGES_LABEL="Images (/images/)" +PLG_TASK_MOKOWAASSYNC_SYNC_IMAGES_DESC="Sync the /images/ directory to the target site." +PLG_TASK_MOKOWAASSYNC_SYNC_FILES_LABEL="Files (/files/)" +PLG_TASK_MOKOWAASSYNC_SYNC_FILES_DESC="Sync the /files/ directory to the target site." +PLG_TASK_MOKOWAASSYNC_SYNC_MEDIA_LABEL="Media (/media/)" +PLG_TASK_MOKOWAASSYNC_SYNC_MEDIA_DESC="Sync the /media/ directory to the target site. Be careful — this includes extension assets." diff --git a/src/packages/plg_task_mokowaassync/src/Extension/ContentSync.php b/src/packages/plg_task_mokowaassync/src/Extension/ContentSync.php index 82015fe..8f452fa 100644 --- a/src/packages/plg_task_mokowaassync/src/Extension/ContentSync.php +++ b/src/packages/plg_task_mokowaassync/src/Extension/ContentSync.php @@ -20,11 +20,16 @@ use Joomla\Event\SubscriberInterface; /** * MokoWaaS Content Sync — Joomla Scheduled Task Plugin. * - * Pushes site content (articles, categories, menus, modules) to - * configured remote MokoWaaS sites on a schedule. Sync targets are - * read from the system plugin params (content_sync fieldset). + * Syncs selected content types to a single remote Joomla site via the + * REST API. Each task instance has its own target URL, API token, and + * content type toggles — so multiple targets can sync independently + * on different schedules. * - * @since 02.27.00 + * Sync strategy: delete-then-push for articles and menus to avoid + * duplicates. Categories are upserted. Files are pushed via the + * MokoWaaS sync-receive endpoint. + * + * @since 02.30.00 */ final class ContentSync extends CMSPlugin implements SubscriberInterface { @@ -48,117 +53,559 @@ final class ContentSync extends CMSPlugin implements SubscriberInterface } /** - * Push content to all configured sync targets. - * - * Reads sync_targets from the MokoWaaS system plugin params, then - * delegates to ContentSyncService. Task-level overrides (if any) - * are merged on top. + * Sync content to the configured target. * * @param ExecuteTaskEvent $event The task event * * @return int Status::OK or Status::KNOCKOUT * - * @since 02.27.00 + * @since 02.30.00 */ private function syncContent(ExecuteTaskEvent $event): int { - $serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/ContentSyncService.php'; + $params = $event->getArgument('params'); - if (!file_exists($serviceFile)) + $targetUrl = rtrim($params->target_url ?? '', '/'); + $apiToken = $params->api_token ?? ''; + + if (empty($targetUrl) || empty($apiToken)) { - $this->logTask('ContentSyncService.php not found — is plg_system_mokowaas installed?'); + $this->logTask('Sync target URL or API token not configured'); return Status::KNOCKOUT; } - require_once $serviceFile; + $errors = 0; + $synced = []; + $apiBase = $targetUrl . '/api/index.php/v1'; - // Read sync targets from the system plugin params - $targets = $this->getSyncTargets(); - - if (empty($targets)) + // Sync content types based on toggles + if ((int) ($params->sync_categories ?? 0) === 1) { - $this->logTask('No sync targets configured in MokoWaaS system plugin'); + $result = $this->syncCategories($apiBase, $apiToken); + $synced[] = 'categories:' . ($result ? 'ok' : 'fail'); + if (!$result) $errors++; + } + + if ((int) ($params->sync_articles ?? 0) === 1) + { + $result = $this->syncArticles($apiBase, $apiToken); + $synced[] = 'articles:' . ($result ? 'ok' : 'fail'); + if (!$result) $errors++; + } + + if ((int) ($params->sync_menus ?? 0) === 1) + { + $result = $this->syncMenus($apiBase, $apiToken); + $synced[] = 'menus:' . ($result ? 'ok' : 'fail'); + if (!$result) $errors++; + } + + if ((int) ($params->sync_modules ?? 0) === 1) + { + $result = $this->syncModules($apiBase, $apiToken); + $synced[] = 'modules:' . ($result ? 'ok' : 'fail'); + if (!$result) $errors++; + } + + // File sync via MokoWaaS endpoint + $healthToken = $this->getHealthToken(); + + if ((int) ($params->sync_images ?? 0) === 1) + { + $result = $this->syncDirectory('images', $targetUrl, $healthToken); + $synced[] = 'images:' . ($result ? 'ok' : 'fail'); + if (!$result) $errors++; + } + + if ((int) ($params->sync_files ?? 0) === 1) + { + $result = $this->syncDirectory('files', $targetUrl, $healthToken); + $synced[] = 'files:' . ($result ? 'ok' : 'fail'); + if (!$result) $errors++; + } + + if ((int) ($params->sync_media ?? 0) === 1) + { + $result = $this->syncDirectory('media', $targetUrl, $healthToken); + $synced[] = 'media:' . ($result ? 'ok' : 'fail'); + if (!$result) $errors++; + } + + $summary = implode(', ', $synced); + + if (empty($synced)) + { + $this->logTask('No content types selected for sync'); return Status::OK; } + $this->logTask("Sync to {$targetUrl}: {$summary}"); + + return $errors > 0 && $errors === count($synced) ? Status::KNOCKOUT : Status::OK; + } + + // ------------------------------------------------------------------ + // Joomla API sync methods + // ------------------------------------------------------------------ + + /** + * Sync articles: delete all on target, then push from source. + * + * @param string $apiBase Target API base URL + * @param string $token API bearer token + * + * @return bool + * + * @since 02.30.00 + */ + private function syncArticles(string $apiBase, string $token): bool + { try { - $service = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncService(); - $result = $service->syncAllTargets($targets); + // Delete all articles on target + $existing = $this->apiGet($apiBase . '/content/articles?page[limit]=0', $token); - $targetResults = $result['targets'] ?? []; - $okCount = 0; - $errCount = 0; - - foreach ($targetResults as $tr) + if ($existing !== null && !empty($existing->data)) { - if (($tr['status'] ?? '') === 'ok') + foreach ($existing->data as $article) { - $okCount++; - } - else - { - $errCount++; - $this->logTask('Sync failed for ' . ($tr['target'] ?? 'unknown') . ': ' . ($tr['message'] ?? '')); + $this->apiDelete($apiBase . '/content/articles/' . $article->attributes->id, $token); } } - $this->logTask(sprintf('Content sync completed — %d ok, %d failed of %d target(s)', $okCount, $errCount, count($targetResults))); + // Read all articles from source + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__content')) + ->where($db->quoteName('state') . ' >= 0'); + $db->setQuery($query); + $articles = $db->loadObjectList(); - return $errCount > 0 && $okCount === 0 ? Status::KNOCKOUT : Status::OK; + // Push each article to target + foreach ($articles as $article) + { + $payload = [ + 'title' => $article->title, + 'alias' => $article->alias, + 'articletext' => $article->introtext . ($article->fulltext ? '
' . $article->fulltext : ''), + 'catid' => $article->catid, + 'state' => $article->state, + 'language' => $article->language, + 'featured' => $article->featured, + 'metadesc' => $article->metadesc ?? '', + 'metakey' => $article->metakey ?? '', + ]; + + $this->apiPost($apiBase . '/content/articles', $token, $payload); + } + + $this->logTask(sprintf('Synced %d articles', count($articles))); + + return true; } catch (\Throwable $e) { - $this->logTask('Content sync failed: ' . $e->getMessage()); + $this->logTask('Article sync failed: ' . $e->getMessage()); - return Status::KNOCKOUT; + return false; } } /** - * Read sync targets from the MokoWaaS system plugin configuration. + * Sync categories: push from source, creating or updating on target. * - * @return array Array of ['url' => ..., 'token' => ..., 'label' => ...] + * @param string $apiBase Target API base URL + * @param string $token API bearer token * - * @since 02.27.00 + * @return bool + * + * @since 02.30.00 */ - private function getSyncTargets(): array + private function syncCategories(string $apiBase, string $token): bool { try { - $db = Factory::getDbo(); + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__categories')) + ->where($db->quoteName('extension') . ' = ' . $db->quote('com_content')) + ->where($db->quoteName('published') . ' >= 0') + ->where($db->quoteName('id') . ' > 1') + ->order($db->quoteName('lft') . ' ASC'); + $db->setQuery($query); + $categories = $db->loadObjectList(); + + foreach ($categories as $cat) + { + $payload = [ + 'title' => $cat->title, + 'alias' => $cat->alias, + 'description' => $cat->description ?? '', + 'published' => $cat->published, + 'language' => $cat->language, + 'extension' => 'com_content', + ]; + + $this->apiPost($apiBase . '/content/categories', $token, $payload); + } + + $this->logTask(sprintf('Synced %d categories', count($categories))); + + return true; + } + catch (\Throwable $e) + { + $this->logTask('Category sync failed: ' . $e->getMessage()); + + return false; + } + } + + /** + * Sync menus: delete all non-system items on target, then push from source. + * + * @param string $apiBase Target API base URL + * @param string $token API bearer token + * + * @return bool + * + * @since 02.30.00 + */ + private function syncMenus(string $apiBase, string $token): bool + { + try + { + // Delete existing menu items on target + $existing = $this->apiGet($apiBase . '/menus/site?page[limit]=0', $token); + + if ($existing !== null && !empty($existing->data)) + { + // Delete in reverse order (children first) + $items = array_reverse((array) $existing->data); + + foreach ($items as $item) + { + $this->apiDelete($apiBase . '/menus/site/' . $item->attributes->id, $token); + } + } + + // Read menu items from source (site menus only, ordered by lft for tree) + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__menu')) + ->where($db->quoteName('client_id') . ' = 0') + ->where($db->quoteName('id') . ' > 1') + ->where($db->quoteName('published') . ' >= 0') + ->order($db->quoteName('lft') . ' ASC'); + $db->setQuery($query); + $items = $db->loadObjectList(); + + foreach ($items as $item) + { + $payload = [ + 'title' => $item->title, + 'alias' => $item->alias, + 'menutype' => $item->menutype, + 'type' => $item->type, + 'link' => $item->link, + 'language' => $item->language, + 'published' => $item->published, + 'home' => $item->home, + ]; + + $this->apiPost($apiBase . '/menus/site', $token, $payload); + } + + $this->logTask(sprintf('Synced %d menu items', count($items))); + + return true; + } + catch (\Throwable $e) + { + $this->logTask('Menu sync failed: ' . $e->getMessage()); + + return false; + } + } + + /** + * Sync modules: push from source. + * + * @param string $apiBase Target API base URL + * @param string $token API bearer token + * + * @return bool + * + * @since 02.30.00 + */ + private function syncModules(string $apiBase, string $token): bool + { + try + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__modules')) + ->where($db->quoteName('client_id') . ' = 0') + ->where($db->quoteName('published') . ' >= 0'); + $db->setQuery($query); + $modules = $db->loadObjectList(); + + foreach ($modules as $mod) + { + $payload = [ + 'title' => $mod->title, + 'module' => $mod->module, + 'position' => $mod->position, + 'params' => $mod->params, + 'language' => $mod->language, + 'published' => $mod->published, + ]; + + $this->apiPost($apiBase . '/modules/site', $token, $payload); + } + + $this->logTask(sprintf('Synced %d modules', count($modules))); + + return true; + } + catch (\Throwable $e) + { + $this->logTask('Module sync failed: ' . $e->getMessage()); + + return false; + } + } + + // ------------------------------------------------------------------ + // File sync via MokoWaaS endpoint + // ------------------------------------------------------------------ + + /** + * Sync a directory to the target site via the MokoWaaS sync-receive endpoint. + * + * @param string $dir Directory name (images, files, media) + * @param string $targetUrl Target site base URL + * @param string $token MokoWaaS health token for auth + * + * @return bool + * + * @since 02.30.00 + */ + private function syncDirectory(string $dir, string $targetUrl, string $token): bool + { + try + { + $sourcePath = JPATH_ROOT . '/' . $dir; + + if (!is_dir($sourcePath)) + { + $this->logTask("Directory /{$dir}/ does not exist — skipping"); + + return true; + } + + // Collect files + $files = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($sourcePath, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($iterator as $file) + { + if ($file->isFile()) + { + $relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($sourcePath) + 1)); + $files[] = [ + 'path' => $dir . '/' . $relativePath, + 'content' => base64_encode(file_get_contents($file->getPathname())), + ]; + } + } + + if (empty($files)) + { + return true; + } + + // Send in batches of 50 files + $batches = array_chunk($files, 50); + + foreach ($batches as $batch) + { + $payload = json_encode([ + 'token' => $token, + 'files' => $batch, + ]); + + $ch = curl_init($targetUrl . '/?mokowaas=syncreceive'); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 120); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + $response = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode < 200 || $httpCode >= 300) + { + $this->logTask("File sync batch failed for /{$dir}/: HTTP {$httpCode}"); + + return false; + } + } + + $this->logTask(sprintf('Synced %d files from /%s/', count($files), $dir)); + + return true; + } + catch (\Throwable $e) + { + $this->logTask("File sync failed for /{$dir}/: " . $e->getMessage()); + + return false; + } + } + + // ------------------------------------------------------------------ + // HTTP helpers + // ------------------------------------------------------------------ + + /** + * GET request to the Joomla API. + * + * @param string $url Full API URL + * @param string $token Bearer token + * + * @return object|null Decoded JSON response + * + * @since 02.30.00 + */ + private function apiGet(string $url, string $token): ?object + { + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $token, + 'Accept: application/vnd.api+json', + ]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + $response = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode >= 200 && $httpCode < 300 && $response) + { + return json_decode($response); + } + + return null; + } + + /** + * POST request to the Joomla API. + * + * @param string $url Full API URL + * @param string $token Bearer token + * @param array $payload Data to send + * + * @return object|null Decoded JSON response + * + * @since 02.30.00 + */ + private function apiPost(string $url, string $token, array $payload): ?object + { + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json', + 'Accept: application/vnd.api+json', + ]); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + $response = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode >= 200 && $httpCode < 300 && $response) + { + return json_decode($response); + } + + return null; + } + + /** + * DELETE request to the Joomla API. + * + * @param string $url Full API URL + * @param string $token Bearer token + * + * @return bool + * + * @since 02.30.00 + */ + private function apiDelete(string $url, string $token): bool + { + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $token, + 'Accept: application/vnd.api+json', + ]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + return $httpCode >= 200 && $httpCode < 300; + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /** + * Read the MokoWaaS health API token from the system plugin params. + * + * @return string + * + * @since 02.30.00 + */ + private function getHealthToken(): string + { + try + { + $db = Factory::getDbo(); $query = $db->getQuery(true) ->select($db->quoteName('params')) ->from($db->quoteName('#__extensions')) ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) ->where($db->quoteName('element') . ' = ' . $db->quote('mokowaas')); - $db->setQuery($query); $raw = $db->loadResult(); + $params = json_decode($raw ?: '{}', true); - if (empty($raw)) - { - return []; - } - - $params = json_decode($raw, true) ?: []; - $targets = $params['sync_targets'] ?? []; - - if (is_string($targets)) - { - $targets = json_decode($targets, true) ?: []; - } - - return is_array($targets) ? $targets : []; + return $params['health_api_token'] ?? ''; } catch (\Throwable $e) { - $this->logTask('Failed to read sync targets: ' . $e->getMessage()); - - return []; + return ''; } } }