From 1f25fe310f63df58d62a87f5f3d970a9bc89a98c Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 13:43:03 -0500 Subject: [PATCH] =?UTF-8?q?feat(sync):=20add=20one-way=20content=20sync=20?= =?UTF-8?q?=E2=80=94=20push=20articles,=20menus,=20modules=20to=20remote?= =?UTF-8?q?=20sites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ContentSyncService (sender) and ContentSyncReceiver (receiver) for pushing articles, categories, menus, and modules from a dev site to remote MokoWaaS sites. Content matched by alias (upsert pattern). Category IDs in menu links encoded as {catid:path} tokens for portable cross-site resolution. Closes #89 Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 + .../api/src/Controller/SyncController.php | 82 ++ .../src/Controller/SyncReceiveController.php | 77 ++ .../Extension/MokoWaaS.php | 118 ++- .../Service/ContentSyncReceiver.php | 819 ++++++++++++++++++ .../Service/ContentSyncService.php | 634 ++++++++++++++ .../forms/sync_target_entry.xml | 15 + .../language/en-GB/plg_system_mokowaas.ini | 14 + .../language/en-US/plg_system_mokowaas.ini | 14 + src/packages/plg_system_mokowaas/mokowaas.xml | 25 +- .../src/Extension/MokoWaaSApi.php | 12 + wiki/api-endpoints.md | 2 + 12 files changed, 1814 insertions(+), 2 deletions(-) create mode 100644 src/packages/com_mokowaas/api/src/Controller/SyncController.php create mode 100644 src/packages/com_mokowaas/api/src/Controller/SyncReceiveController.php create mode 100644 src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php create mode 100644 src/packages/plg_system_mokowaas/Service/ContentSyncService.php create mode 100644 src/packages/plg_system_mokowaas/forms/sync_target_entry.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index cb155ea2..b9af7b19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ - REST endpoints `POST /api/v1/mokowaas/reset` and `GET/POST /api/v1/mokowaas/snapshot` - `plg_task_mokowaasdemo` — Joomla Scheduled Task plugin for automatic demo site reset - Admin toggles: Take Snapshot Now and Restore Baseline Now in plugin config +- Content Sync: one-way push of articles, categories, menus, and modules to remote MokoWaaS sites +- Content Sync: API endpoints `POST /?mokowaas=sync` (sender) and `POST /?mokowaas=sync-receive` (receiver) +- Content Sync: REST endpoints `POST /api/v1/mokowaas/sync` and `POST /api/v1/mokowaas/sync-receive` +- Content Sync: configurable sync targets with URL + API token in plugin settings ## [02.20.00] --- 2026-05-28 diff --git a/src/packages/com_mokowaas/api/src/Controller/SyncController.php b/src/packages/com_mokowaas/api/src/Controller/SyncController.php new file mode 100644 index 00000000..3e89f09c --- /dev/null +++ b/src/packages/com_mokowaas/api/src/Controller/SyncController.php @@ -0,0 +1,82 @@ +input->getMethod() !== 'POST') + { + $this->sendJson(405, ['error' => 'POST required']); + return; + } + + $user = $app->getIdentity(); + + if (!$user->authorise('core.manage', 'com_plugins')) + { + $this->sendJson(403, ['error' => 'Not authorized']); + return; + } + + $plugin = PluginHelper::getPlugin('system', 'mokowaas'); + + if (!$plugin) + { + $this->sendJson(503, ['error' => 'MokoWaaS system plugin not enabled']); + return; + } + + try + { + $params = new Registry($plugin->params); + $targets = json_decode($params->get('sync_targets', '[]'), true) ?: []; + + $serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/ContentSyncService.php'; + require_once $serviceFile; + + $service = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncService(); + $result = $service->syncAllTargets($targets); + + $this->sendJson(200, $result); + } + catch (\Throwable $e) + { + $this->sendJson(500, ['error' => 'Sync failed', 'message' => $e->getMessage()]); + } + } + + private function sendJson(int $code, array $payload): void + { + $app = Factory::getApplication(); + $app->setHeader('Content-Type', 'application/json', true); + $app->setHeader('Status', (string) $code, true); + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + $app->close(); + } +} diff --git a/src/packages/com_mokowaas/api/src/Controller/SyncReceiveController.php b/src/packages/com_mokowaas/api/src/Controller/SyncReceiveController.php new file mode 100644 index 00000000..60a734db --- /dev/null +++ b/src/packages/com_mokowaas/api/src/Controller/SyncReceiveController.php @@ -0,0 +1,77 @@ +input->getMethod() !== 'POST') + { + $this->sendJson(405, ['error' => 'POST required']); + return; + } + + $user = $app->getIdentity(); + + if (!$user->authorise('core.manage', 'com_plugins')) + { + $this->sendJson(403, ['error' => 'Not authorized']); + return; + } + + try + { + $payload = json_decode($app->input->json->getRaw(), true); + + if (empty($payload['mokowaas_sync'])) + { + $this->sendJson(400, ['error' => 'Invalid payload — missing mokowaas_sync version']); + return; + } + + $serviceFile = JPATH_PLUGINS . '/system/mokowaas/Service/ContentSyncReceiver.php'; + require_once $serviceFile; + + $receiver = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncReceiver(); + $result = $receiver->receive($payload); + + $this->sendJson(200, $result); + } + catch (\Throwable $e) + { + $this->sendJson(500, ['error' => 'Sync receive failed', 'message' => $e->getMessage()]); + } + } + + private function sendJson(int $code, array $payload): void + { + $app = Factory::getApplication(); + $app->setHeader('Content-Type', 'application/json', true); + $app->setHeader('Status', (string) $code, true); + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + $app->close(); + } +} diff --git a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php index fcfc031a..17f296b9 100644 --- a/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php +++ b/src/packages/plg_system_mokowaas/Extension/MokoWaaS.php @@ -916,6 +916,35 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface } } + // Content Sync: Push Now + if ((int) $params->get('sync_push_now', 0) === 1) + { + $params->set('sync_push_now', '0'); + $changed = true; + + try + { + require_once __DIR__ . '/../Service/ContentSyncService.php'; + + $targets = json_decode($params->get('sync_targets', '[]'), true) ?: []; + $service = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncService(); + $result = $service->syncAllTargets($targets); + + $targetCount = count($result['targets'] ?? []); + $app->enqueueMessage( + sprintf('Content sync pushed to %d target(s).', $targetCount), + 'message' + ); + } + catch (\Throwable $e) + { + $app->enqueueMessage( + 'Content sync failed: ' . $e->getMessage(), + 'error' + ); + } + } + if ($changed) { $db = Factory::getDbo(); @@ -1532,11 +1561,17 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface case 'snapshot': $this->handleSnapshotAction(); break; + case 'sync': + $this->handleSyncAction(); + break; + case 'sync-receive': + $this->handleSyncReceiveAction(); + break; default: $this->sendHealthResponse(400, [ 'error' => 'Unknown action', 'action' => $action, - 'available' => ['health', 'install', 'update', 'cache', 'backup', 'info', 'reset', 'snapshot'], + 'available' => ['health', 'install', 'update', 'cache', 'backup', 'info', 'reset', 'snapshot', 'sync', 'sync-receive'], ]); break; } @@ -1657,6 +1692,87 @@ class MokoWaaS extends CMSPlugin implements BootableExtensionInterface ); } + /** + * Handle content sync push to configured targets. + * + * POST /?mokowaas=sync + * + * @return void + * @since 02.21.00 + */ + protected function handleSyncAction() + { + if ($this->app->input->getMethod() !== 'POST') + { + $this->sendHealthResponse(405, ['error' => 'POST required']); + + return; + } + + try + { + require_once __DIR__ . '/../Service/ContentSyncService.php'; + + $targets = json_decode($this->params->get('sync_targets', '[]'), true) ?: []; + + $service = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncService(); + $result = $service->syncAllTargets($targets); + + $this->sendHealthResponse(200, $result); + } + catch (\Throwable $e) + { + $this->sendHealthResponse(500, [ + 'error' => 'Sync failed', + 'message' => $e->getMessage(), + ]); + } + } + + /** + * Handle incoming content sync payload (receiver side). + * + * POST /?mokowaas=sync-receive + * + * @return void + * @since 02.21.00 + */ + protected function handleSyncReceiveAction() + { + if ($this->app->input->getMethod() !== 'POST') + { + $this->sendHealthResponse(405, ['error' => 'POST required']); + + return; + } + + try + { + $payload = json_decode(file_get_contents('php://input'), true); + + if (empty($payload['mokowaas_sync'])) + { + $this->sendHealthResponse(400, ['error' => 'Invalid payload — missing mokowaas_sync version']); + + return; + } + + require_once __DIR__ . '/../Service/ContentSyncReceiver.php'; + + $receiver = new \Moko\Plugin\System\MokoWaaS\Service\ContentSyncReceiver(); + $result = $receiver->receive($payload); + + $this->sendHealthResponse(200, $result); + } + catch (\Throwable $e) + { + $this->sendHealthResponse(500, [ + 'error' => 'Sync receive failed', + 'message' => $e->getMessage(), + ]); + } + } + /** * Trigger Joomla update finder check. * diff --git a/src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php b/src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php new file mode 100644 index 00000000..f0091f23 --- /dev/null +++ b/src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php @@ -0,0 +1,819 @@ +db = $db ?: Factory::getDbo(); + } + + /** + * Process an incoming sync payload. + * + * @param array $payload Decoded JSON payload from the source site + * + * @return array Result summary with per-type counts and warnings + * + * @since 02.21.00 + */ + public function receive(array $payload): array + { + if (empty($payload['mokowaas_sync'])) + { + return ['status' => 'error', 'message' => 'Invalid payload — missing mokowaas_sync version']; + } + + $this->warnings = []; + $results = []; + + // Apply in dependency order + try + { + $results['categories'] = $this->applyCategories($payload['categories'] ?? []); + } + catch (\Throwable $e) + { + $results['categories'] = ['error' => $e->getMessage()]; + $this->warnings[] = 'Categories failed: ' . $e->getMessage(); + } + + try + { + $results['articles'] = $this->applyArticles($payload['articles'] ?? []); + } + catch (\Throwable $e) + { + $results['articles'] = ['error' => $e->getMessage()]; + $this->warnings[] = 'Articles failed: ' . $e->getMessage(); + } + + try + { + $results['menu_types'] = $this->applyMenuTypes($payload['menu_types'] ?? []); + } + catch (\Throwable $e) + { + $results['menu_types'] = ['error' => $e->getMessage()]; + $this->warnings[] = 'Menu types failed: ' . $e->getMessage(); + } + + try + { + $results['menu_items'] = $this->applyMenuItems($payload['menu_items'] ?? []); + } + catch (\Throwable $e) + { + $results['menu_items'] = ['error' => $e->getMessage()]; + $this->warnings[] = 'Menu items failed: ' . $e->getMessage(); + } + + try + { + $results['modules'] = $this->applyModules($payload['modules'] ?? []); + } + catch (\Throwable $e) + { + $results['modules'] = ['error' => $e->getMessage()]; + $this->warnings[] = 'Modules failed: ' . $e->getMessage(); + } + + Log::add( + sprintf('Content sync received from %s', $payload['source_site'] ?? 'unknown'), + Log::INFO, + 'mokowaas' + ); + + return [ + 'status' => 'ok', + 'message' => 'Sync applied', + 'source_site' => $payload['source_site'] ?? '', + 'results' => $results, + 'warnings' => $this->warnings, + ]; + } + + /** + * Apply categories — sorted by path depth (shallow first). + * + * @param array $categories Category data from payload + * + * @return array ['inserted' => N, 'updated' => N] + * + * @since 02.21.00 + */ + private function applyCategories(array $categories): array + { + $db = $this->db; + $inserted = 0; + $updated = 0; + + // Sort by path depth — parents before children + usort($categories, function ($a, $b) { + return substr_count($a['path'], '/') - substr_count($b['path'], '/'); + }); + + foreach ($categories as $cat) + { + $alias = $cat['alias'] ?? ''; + $path = $cat['path'] ?? $alias; + + if (empty($alias) || !preg_match('/^[a-z0-9\-\/]+$/i', $path)) + { + $this->warnings[] = 'Skipped category with invalid alias/path: ' . $alias; + continue; + } + + // Resolve parent ID from path + $parentId = 1; // Root + $pathParts = explode('/', $path); + + if (count($pathParts) > 1) + { + $parentPath = implode('/', array_slice($pathParts, 0, -1)); + $parentId = $this->resolveCategoryPath($parentPath); + + if ($parentId === 0) + { + $this->warnings[] = 'Parent category not found for: ' . $path; + $parentId = 1; + } + } + + // Check if category exists + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__categories')) + ->where($db->quoteName('alias') . ' = ' . $db->quote($alias)) + ->where($db->quoteName('extension') . ' = ' . $db->quote('com_content')) + ->where($db->quoteName('parent_id') . ' = ' . (int) $parentId); + + $db->setQuery($query); + $existingId = (int) $db->loadResult(); + + $now = Factory::getDate()->toSql(); + + if ($existingId) + { + $query = $db->getQuery(true) + ->update($db->quoteName('#__categories')) + ->set($db->quoteName('title') . ' = ' . $db->quote($cat['title'])) + ->set($db->quoteName('description') . ' = ' . $db->quote($cat['description'] ?? '')) + ->set($db->quoteName('published') . ' = ' . (int) ($cat['published'] ?? 1)) + ->set($db->quoteName('access') . ' = ' . (int) ($cat['access'] ?? 1)) + ->set($db->quoteName('language') . ' = ' . $db->quote($cat['language'] ?? '*')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($cat['params'] ?? new \stdClass))) + ->set($db->quoteName('metadata') . ' = ' . $db->quote(json_encode($cat['metadata'] ?? new \stdClass))) + ->set($db->quoteName('modified_time') . ' = ' . $db->quote($now)) + ->where($db->quoteName('id') . ' = ' . $existingId); + + $db->setQuery($query); + $db->execute(); + $updated++; + + $this->catPathCache[$path] = $existingId; + } + else + { + $obj = (object) [ + 'title' => $cat['title'], + 'alias' => $alias, + 'path' => $path, + 'extension' => 'com_content', + 'description' => $cat['description'] ?? '', + 'published' => (int) ($cat['published'] ?? 1), + 'access' => (int) ($cat['access'] ?? 1), + 'language' => $cat['language'] ?? '*', + 'params' => json_encode($cat['params'] ?? new \stdClass), + 'metadata' => json_encode($cat['metadata'] ?? new \stdClass), + 'parent_id' => $parentId, + 'level' => count($pathParts), + 'lft' => 0, + 'rgt' => 0, + 'created_time' => $now, + 'modified_time' => $now, + ]; + + $db->insertObject('#__categories', $obj, 'id'); + $inserted++; + + $this->catPathCache[$path] = (int) $obj->id; + + // Rebuild category tree for this extension + $this->rebuildCategoryTree(); + } + } + + return ['inserted' => $inserted, 'updated' => $updated]; + } + + /** + * Apply articles — resolve category by alias path, upsert by alias. + * + * @param array $articles Article data from payload + * + * @return array ['inserted' => N, 'updated' => N] + * + * @since 02.21.00 + */ + private function applyArticles(array $articles): array + { + $db = $this->db; + $inserted = 0; + $updated = 0; + + foreach ($articles as $article) + { + $alias = $article['alias'] ?? ''; + + if (empty($alias)) + { + continue; + } + + // Resolve category + $catPath = $article['catid_alias_path'] ?? 'uncategorised'; + $catId = $this->resolveCategoryPath($catPath); + + if ($catId === 0) + { + $catId = 2; // Joomla's built-in Uncategorised + $this->warnings[] = 'Category "' . $catPath . '" not found for article "' . $alias . '" — assigned to Uncategorised'; + } + + // Check if article exists + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__content')) + ->where($db->quoteName('alias') . ' = ' . $db->quote($alias)); + + $db->setQuery($query); + $existingId = (int) $db->loadResult(); + + $now = Factory::getDate()->toSql(); + + if ($existingId) + { + $query = $db->getQuery(true) + ->update($db->quoteName('#__content')) + ->set($db->quoteName('title') . ' = ' . $db->quote($article['title'])) + ->set($db->quoteName('introtext') . ' = ' . $db->quote($article['introtext'] ?? '')) + ->set($db->quoteName('fulltext') . ' = ' . $db->quote($article['fulltext'] ?? '')) + ->set($db->quoteName('state') . ' = ' . (int) ($article['state'] ?? 1)) + ->set($db->quoteName('catid') . ' = ' . $catId) + ->set($db->quoteName('access') . ' = ' . (int) ($article['access'] ?? 1)) + ->set($db->quoteName('language') . ' = ' . $db->quote($article['language'] ?? '*')) + ->set($db->quoteName('featured') . ' = ' . (int) ($article['featured'] ?? 0)) + ->set($db->quoteName('metadata') . ' = ' . $db->quote(json_encode($article['metadata'] ?? new \stdClass))) + ->set($db->quoteName('attribs') . ' = ' . $db->quote(json_encode($article['attribs'] ?? new \stdClass))) + ->set($db->quoteName('images') . ' = ' . $db->quote(json_encode($article['images'] ?? new \stdClass))) + ->set($db->quoteName('urls') . ' = ' . $db->quote(json_encode($article['urls'] ?? new \stdClass))) + ->set($db->quoteName('modified') . ' = ' . $db->quote($now)) + ->where($db->quoteName('id') . ' = ' . $existingId); + + $db->setQuery($query); + $db->execute(); + $updated++; + } + else + { + $obj = (object) [ + 'title' => $article['title'], + 'alias' => $alias, + 'introtext' => $article['introtext'] ?? '', + 'fulltext' => $article['fulltext'] ?? '', + 'state' => (int) ($article['state'] ?? 1), + 'catid' => $catId, + 'access' => (int) ($article['access'] ?? 1), + 'language' => $article['language'] ?? '*', + 'featured' => (int) ($article['featured'] ?? 0), + 'publish_up' => $article['publish_up'] ?? $now, + 'publish_down' => $article['publish_down'], + 'metadata' => json_encode($article['metadata'] ?? new \stdClass), + 'attribs' => json_encode($article['attribs'] ?? new \stdClass), + 'images' => json_encode($article['images'] ?? new \stdClass), + 'urls' => json_encode($article['urls'] ?? new \stdClass), + 'created' => $now, + 'modified' => $now, + ]; + + $db->insertObject('#__content', $obj, 'id'); + $inserted++; + } + } + + return ['inserted' => $inserted, 'updated' => $updated]; + } + + /** + * Apply menu types — insert if not exists. + * + * @param array $menuTypes Menu type data from payload + * + * @return array ['inserted' => N, 'updated' => N] + * + * @since 02.21.00 + */ + private function applyMenuTypes(array $menuTypes): array + { + $db = $this->db; + $inserted = 0; + $updated = 0; + + foreach ($menuTypes as $mt) + { + $menutype = $mt['menutype'] ?? ''; + + if (empty($menutype)) + { + continue; + } + + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__menu_types')) + ->where($db->quoteName('menutype') . ' = ' . $db->quote($menutype)); + + $db->setQuery($query); + + if ($db->loadResult()) + { + $query = $db->getQuery(true) + ->update($db->quoteName('#__menu_types')) + ->set($db->quoteName('title') . ' = ' . $db->quote($mt['title'] ?? $menutype)) + ->set($db->quoteName('description') . ' = ' . $db->quote($mt['description'] ?? '')) + ->where($db->quoteName('menutype') . ' = ' . $db->quote($menutype)); + + $db->setQuery($query); + $db->execute(); + $updated++; + } + else + { + $obj = (object) [ + 'title' => $mt['title'] ?? $menutype, + 'menutype' => $menutype, + 'description' => $mt['description'] ?? '', + ]; + + $db->insertObject('#__menu_types', $obj); + $inserted++; + } + } + + return ['inserted' => $inserted, 'updated' => $updated]; + } + + /** + * Apply menu items — resolve parent aliases and {catid:path} tokens. + * + * @param array $items Menu item data from payload + * + * @return array ['inserted' => N, 'updated' => N] + * + * @since 02.21.00 + */ + private function applyMenuItems(array $items): array + { + $db = $this->db; + $inserted = 0; + $updated = 0; + + // Sort: root items first, then children + usort($items, function ($a, $b) { + $aIsRoot = empty($a['parent_alias']); + $bIsRoot = empty($b['parent_alias']); + + if ($aIsRoot && !$bIsRoot) return -1; + if (!$aIsRoot && $bIsRoot) return 1; + + return 0; + }); + + // Resolve component IDs + $compQuery = $db->getQuery(true) + ->select([$db->quoteName('extension_id'), $db->quoteName('element')]) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')); + $db->setQuery($compQuery); + $compMap = []; + + foreach ($db->loadAssocList() ?: [] as $c) + { + $compMap[$c['element']] = (int) $c['extension_id']; + } + + foreach ($items as $item) + { + $alias = $item['alias'] ?? ''; + $menutype = $item['menutype'] ?? ''; + + if (empty($alias) || empty($menutype)) + { + continue; + } + + // Resolve parent + $parentId = 1; // Root menu item + + if (!empty($item['parent_alias'])) + { + $parentId = $this->resolveMenuAlias($menutype, $item['parent_alias']); + + if ($parentId === 0) + { + $this->warnings[] = 'Parent menu item "' . $item['parent_alias'] . '" not found for "' . $alias . '"'; + $parentId = 1; + } + } + + // Resolve {catid:path} tokens in link + $link = $item['link'] ?? ''; + + if (preg_match_all('/\{catid:([^}]+)\}/', $link, $matches)) + { + foreach ($matches[1] as $i => $catPath) + { + $localCatId = $this->resolveCategoryPath($catPath); + + if ($localCatId > 0) + { + $link = str_replace($matches[0][$i], (string) $localCatId, $link); + } + else + { + $this->warnings[] = 'Could not resolve {catid:' . $catPath . '} in menu item "' . $alias . '"'; + $link = str_replace($matches[0][$i], '0', $link); + } + } + } + + $componentId = $compMap[$item['component_name'] ?? ''] ?? 0; + + // Check if menu item exists + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__menu')) + ->where($db->quoteName('alias') . ' = ' . $db->quote($alias)) + ->where($db->quoteName('menutype') . ' = ' . $db->quote($menutype)) + ->where($db->quoteName('client_id') . ' = 0'); + + $db->setQuery($query); + $existingId = (int) $db->loadResult(); + + if ($existingId) + { + $query = $db->getQuery(true) + ->update($db->quoteName('#__menu')) + ->set($db->quoteName('title') . ' = ' . $db->quote($item['title'])) + ->set($db->quoteName('link') . ' = ' . $db->quote($link)) + ->set($db->quoteName('type') . ' = ' . $db->quote($item['type'] ?? 'component')) + ->set($db->quoteName('published') . ' = ' . (int) ($item['published'] ?? 1)) + ->set($db->quoteName('access') . ' = ' . (int) ($item['access'] ?? 1)) + ->set($db->quoteName('language') . ' = ' . $db->quote($item['language'] ?? '*')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($item['params'] ?? new \stdClass))) + ->set($db->quoteName('parent_id') . ' = ' . $parentId) + ->set($db->quoteName('component_id') . ' = ' . $componentId) + ->set($db->quoteName('home') . ' = ' . (int) ($item['home'] ?? 0)) + ->where($db->quoteName('id') . ' = ' . $existingId); + + $db->setQuery($query); + $db->execute(); + $updated++; + } + else + { + $obj = (object) [ + 'menutype' => $menutype, + 'title' => $item['title'], + 'alias' => $alias, + 'path' => $alias, + 'link' => $link, + 'type' => $item['type'] ?? 'component', + 'published' => (int) ($item['published'] ?? 1), + 'parent_id' => $parentId, + 'level' => $parentId <= 1 ? 1 : 2, + 'component_id' => $componentId, + 'access' => (int) ($item['access'] ?? 1), + 'language' => $item['language'] ?? '*', + 'params' => json_encode($item['params'] ?? new \stdClass), + 'home' => (int) ($item['home'] ?? 0), + 'client_id' => 0, + 'lft' => 0, + 'rgt' => 0, + ]; + + $db->insertObject('#__menu', $obj, 'id'); + $inserted++; + } + } + + // Rebuild menu tree for each affected menutype + $affectedMenuTypes = array_unique(array_column($items, 'menutype')); + + foreach ($affectedMenuTypes as $mt) + { + $this->rebuildMenuTree($mt); + } + + return ['inserted' => $inserted, 'updated' => $updated]; + } + + /** + * Apply modules — upsert by title+position+client_id, rebuild menu assignments. + * + * @param array $modules Module data from payload + * + * @return array ['inserted' => N, 'updated' => N] + * + * @since 02.21.00 + */ + private function applyModules(array $modules): array + { + $db = $this->db; + $inserted = 0; + $updated = 0; + + foreach ($modules as $mod) + { + $title = $mod['title'] ?? ''; + $position = $mod['position'] ?? ''; + $clientId = (int) ($mod['client_id'] ?? 0); + + if (empty($title)) + { + continue; + } + + // Check existence by title + position + client_id + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__modules')) + ->where($db->quoteName('title') . ' = ' . $db->quote($title)) + ->where($db->quoteName('position') . ' = ' . $db->quote($position)) + ->where($db->quoteName('client_id') . ' = ' . $clientId); + + $db->setQuery($query); + $existingId = (int) $db->loadResult(); + + if ($existingId) + { + $query = $db->getQuery(true) + ->update($db->quoteName('#__modules')) + ->set($db->quoteName('module') . ' = ' . $db->quote($mod['module'] ?? '')) + ->set($db->quoteName('content') . ' = ' . $db->quote($mod['content'] ?? '')) + ->set($db->quoteName('published') . ' = ' . (int) ($mod['published'] ?? 1)) + ->set($db->quoteName('access') . ' = ' . (int) ($mod['access'] ?? 1)) + ->set($db->quoteName('language') . ' = ' . $db->quote($mod['language'] ?? '*')) + ->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($mod['params'] ?? new \stdClass))) + ->where($db->quoteName('id') . ' = ' . $existingId); + + $db->setQuery($query); + $db->execute(); + $updated++; + + $moduleId = $existingId; + } + else + { + $obj = (object) [ + 'title' => $title, + 'module' => $mod['module'] ?? '', + 'position' => $position, + 'content' => $mod['content'] ?? '', + 'published' => (int) ($mod['published'] ?? 1), + 'access' => (int) ($mod['access'] ?? 1), + 'language' => $mod['language'] ?? '*', + 'params' => json_encode($mod['params'] ?? new \stdClass), + 'client_id' => $clientId, + 'ordering' => 0, + ]; + + $db->insertObject('#__modules', $obj, 'id'); + $inserted++; + $moduleId = (int) $obj->id; + } + + // Rebuild menu assignments + $query = $db->getQuery(true) + ->delete($db->quoteName('#__modules_menu')) + ->where($db->quoteName('moduleid') . ' = ' . $moduleId); + $db->setQuery($query); + $db->execute(); + + $assignment = $mod['menu_assignment'] ?? []; + $assignType = (int) ($assignment['assignment'] ?? 0); + $aliases = $assignment['menu_item_aliases'] ?? []; + + if ($assignType === 0 || empty($aliases)) + { + // All pages + $obj = (object) ['moduleid' => $moduleId, 'menuid' => 0]; + $db->insertObject('#__modules_menu', $obj); + } + else + { + foreach ($aliases as $aliasRef) + { + // Format: "menutype:alias" + $parts = explode(':', $aliasRef, 2); + + if (count($parts) !== 2) + { + continue; + } + + $menuId = $this->resolveMenuAlias($parts[0], $parts[1]); + + if ($menuId > 0) + { + $menuidValue = $assignType === -1 ? -$menuId : $menuId; + $obj = (object) ['moduleid' => $moduleId, 'menuid' => $menuidValue]; + $db->insertObject('#__modules_menu', $obj); + } + else + { + $this->warnings[] = 'Module "' . $title . '": menu item "' . $aliasRef . '" not found'; + } + } + } + } + + return ['inserted' => $inserted, 'updated' => $updated]; + } + + /** + * Resolve a category alias path to a local category ID. + * + * @param string $path Slash-delimited alias path (e.g. "blog/news") + * + * @return int Category ID, or 0 if not found + * + * @since 02.21.00 + */ + private function resolveCategoryPath(string $path): int + { + if (isset($this->catPathCache[$path])) + { + return $this->catPathCache[$path]; + } + + $db = $this->db; + $segments = explode('/', $path); + $parentId = 1; + + foreach ($segments as $segment) + { + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__categories')) + ->where($db->quoteName('alias') . ' = ' . $db->quote($segment)) + ->where($db->quoteName('parent_id') . ' = ' . $parentId) + ->where($db->quoteName('extension') . ' = ' . $db->quote('com_content')); + + $db->setQuery($query); + $id = (int) $db->loadResult(); + + if ($id === 0) + { + $this->catPathCache[$path] = 0; + + return 0; + } + + $parentId = $id; + } + + $this->catPathCache[$path] = $parentId; + + return $parentId; + } + + /** + * Resolve a menu item alias to a local menu ID. + * + * @param string $menutype Menu type key + * @param string $alias Menu item alias + * + * @return int Menu item ID, or 0 if not found + * + * @since 02.21.00 + */ + private function resolveMenuAlias(string $menutype, string $alias): int + { + $db = $this->db; + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__menu')) + ->where($db->quoteName('alias') . ' = ' . $db->quote($alias)) + ->where($db->quoteName('menutype') . ' = ' . $db->quote($menutype)) + ->where($db->quoteName('client_id') . ' = 0'); + + $db->setQuery($query); + + return (int) $db->loadResult(); + } + + /** + * Rebuild the nested set (lft/rgt) for the category tree. + * + * Uses Joomla's built-in Table rebuild method. + * + * @return void + * + * @since 02.21.00 + */ + private function rebuildCategoryTree(): void + { + try + { + $table = \Joomla\CMS\Table\Table::getInstance('Category'); + $table->rebuild(); + } + catch (\Throwable $e) + { + $this->warnings[] = 'Category tree rebuild failed: ' . $e->getMessage(); + } + } + + /** + * Rebuild the nested set (lft/rgt) for a menu type. + * + * @param string $menutype Menu type to rebuild + * + * @return void + * + * @since 02.21.00 + */ + private function rebuildMenuTree(string $menutype): void + { + try + { + $table = \Joomla\CMS\Table\Table::getInstance('Menu'); + $table->rebuild(); + } + catch (\Throwable $e) + { + $this->warnings[] = 'Menu tree rebuild failed for "' . $menutype . '": ' . $e->getMessage(); + } + } +} diff --git a/src/packages/plg_system_mokowaas/Service/ContentSyncService.php b/src/packages/plg_system_mokowaas/Service/ContentSyncService.php new file mode 100644 index 00000000..e5ebea26 --- /dev/null +++ b/src/packages/plg_system_mokowaas/Service/ContentSyncService.php @@ -0,0 +1,634 @@ +db = $db ?: Factory::getDbo(); + } + + /** + * Build the full sync payload from local content. + * + * @return array Structured payload ready for JSON encoding + * + * @since 02.21.00 + */ + public function buildPayload(): array + { + $this->catPathMap = $this->buildCategoryPathMap(); + + return [ + 'mokowaas_sync' => '1.0', + 'source_site' => rtrim(Uri::root(), '/'), + 'generated_at' => gmdate('Y-m-d\TH:i:s\Z'), + 'categories' => $this->buildCategoryPayload(), + 'articles' => $this->buildArticlePayload(), + 'menu_types' => $this->buildMenuTypePayload(), + 'menu_items' => $this->buildMenuItemPayload(), + 'modules' => $this->buildModulePayload(), + ]; + } + + /** + * Push the sync payload to a single target site. + * + * @param string $targetUrl Base URL of the target site + * @param string $token health_api_token for the target + * + * @return array Result with status, message, and per-type counts + * + * @since 02.21.00 + */ + public function pushToTarget(string $targetUrl, string $token): array + { + $payload = $this->buildPayload(); + $jsonBody = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $endpoint = rtrim($targetUrl, '/') . '/?mokowaas=sync-receive'; + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Authorization: Bearer {$token}\r\nContent-Type: application/json\r\n", + 'content' => $jsonBody, + 'timeout' => self::HTTP_TIMEOUT, + 'ignore_errors' => true, + ], + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + ], + ]); + + $response = @file_get_contents($endpoint, false, $context); + + if ($response === false) + { + return [ + 'status' => 'error', + 'target' => $targetUrl, + 'message' => 'Connection failed — target unreachable', + ]; + } + + // Parse HTTP status from response headers + $httpCode = 0; + + if (isset($http_response_header[0])) + { + preg_match('/\d{3}/', $http_response_header[0], $matches); + $httpCode = (int) ($matches[0] ?? 0); + } + + $result = json_decode($response, true); + + if ($httpCode >= 400 || !$result) + { + return [ + 'status' => 'error', + 'target' => $targetUrl, + 'http_code' => $httpCode, + 'message' => $result['error'] ?? $result['message'] ?? 'Unknown error from target', + ]; + } + + $result['target'] = $targetUrl; + + return $result; + } + + /** + * Push content to all configured sync targets. + * + * @param array $targets Array of ['url' => ..., 'token' => ..., 'label' => ...] + * + * @return array Per-target results + * + * @since 02.21.00 + */ + public function syncAllTargets(array $targets): array + { + $results = []; + + foreach ($targets as $target) + { + $url = $target['url'] ?? ''; + $token = $target['token'] ?? ''; + $label = $target['label'] ?? $url; + + if (empty($url) || empty($token)) + { + $results[] = [ + 'status' => 'skipped', + 'target' => $label, + 'message' => 'Missing URL or token', + ]; + continue; + } + + try + { + $result = $this->pushToTarget($url, $token); + $result['label'] = $label; + $results[] = $result; + } + catch (\Throwable $e) + { + $results[] = [ + 'status' => 'error', + 'target' => $label, + 'message' => $e->getMessage(), + ]; + } + } + + Log::add( + sprintf('Content sync pushed to %d target(s)', count($targets)), + Log::INFO, + 'mokowaas' + ); + + return [ + 'status' => 'ok', + 'message' => sprintf('Sync completed for %d target(s)', count($results)), + 'targets' => $results, + ]; + } + + /** + * Build category ID → alias path map. + * + * @return array [id => 'parent-alias/child-alias'] + * + * @since 02.21.00 + */ + private function buildCategoryPathMap(): array + { + $db = $this->db; + $query = $db->getQuery(true) + ->select([$db->quoteName('id'), $db->quoteName('alias'), $db->quoteName('parent_id')]) + ->from($db->quoteName('#__categories')) + ->where($db->quoteName('extension') . ' = ' . $db->quote('com_content')) + ->where($db->quoteName('published') . ' != -2') + ->where($db->quoteName('id') . ' > 1'); + + $db->setQuery($query); + $rows = $db->loadAssocList('id'); + + $map = []; + + foreach ($rows as $id => $row) + { + $map[$id] = $this->resolvePathFromRows($id, $rows); + } + + return $map; + } + + /** + * Recursively build alias path for a category ID. + * + * @param int $id Category ID + * @param array $rows All category rows keyed by ID + * + * @return string Slash-delimited alias path + * + * @since 02.21.00 + */ + private function resolvePathFromRows(int $id, array $rows): string + { + if (!isset($rows[$id])) + { + return ''; + } + + $row = $rows[$id]; + $parentId = (int) $row['parent_id']; + + if ($parentId <= 1 || !isset($rows[$parentId])) + { + return $row['alias']; + } + + return $this->resolvePathFromRows($parentId, $rows) . '/' . $row['alias']; + } + + /** + * Build category payload. + * + * @return array + * + * @since 02.21.00 + */ + private function buildCategoryPayload(): array + { + $db = $this->db; + $query = $db->getQuery(true) + ->select([ + $db->quoteName('id'), + $db->quoteName('title'), + $db->quoteName('alias'), + $db->quoteName('description'), + $db->quoteName('published'), + $db->quoteName('access'), + $db->quoteName('language'), + $db->quoteName('params'), + $db->quoteName('metadata'), + ]) + ->from($db->quoteName('#__categories')) + ->where($db->quoteName('extension') . ' = ' . $db->quote('com_content')) + ->where($db->quoteName('published') . ' != -2') + ->where($db->quoteName('id') . ' > 1') + ->order($db->quoteName('lft') . ' ASC') + ->setLimit(self::MAX_ITEMS); + + $db->setQuery($query); + $rows = $db->loadAssocList(); + + $categories = []; + + foreach ($rows as $row) + { + $categories[] = [ + 'title' => $row['title'], + 'alias' => $row['alias'], + 'path' => $this->catPathMap[(int) $row['id']] ?? $row['alias'], + 'description' => $row['description'] ?? '', + 'published' => (int) $row['published'], + 'access' => (int) $row['access'], + 'language' => $row['language'], + 'params' => json_decode($row['params'] ?: '{}', true), + 'metadata' => json_decode($row['metadata'] ?: '{}', true), + ]; + } + + return $categories; + } + + /** + * Build article payload. + * + * @return array + * + * @since 02.21.00 + */ + private function buildArticlePayload(): array + { + $db = $this->db; + $query = $db->getQuery(true) + ->select([ + $db->quoteName('title'), + $db->quoteName('alias'), + $db->quoteName('introtext'), + $db->quoteName('fulltext'), + $db->quoteName('state'), + $db->quoteName('catid'), + $db->quoteName('access'), + $db->quoteName('language'), + $db->quoteName('featured'), + $db->quoteName('publish_up'), + $db->quoteName('publish_down'), + $db->quoteName('metadata'), + $db->quoteName('attribs'), + $db->quoteName('images'), + $db->quoteName('urls'), + ]) + ->from($db->quoteName('#__content')) + ->where($db->quoteName('state') . ' != -2') + ->order($db->quoteName('id') . ' ASC') + ->setLimit(self::MAX_ITEMS); + + $db->setQuery($query); + $rows = $db->loadAssocList(); + + $articles = []; + + foreach ($rows as $row) + { + $articles[] = [ + 'title' => $row['title'], + 'alias' => $row['alias'], + 'introtext' => $row['introtext'], + 'fulltext' => $row['fulltext'], + 'state' => (int) $row['state'], + 'catid_alias_path' => $this->catPathMap[(int) $row['catid']] ?? 'uncategorised', + 'access' => (int) $row['access'], + 'language' => $row['language'], + 'featured' => (int) $row['featured'], + 'publish_up' => $row['publish_up'], + 'publish_down' => $row['publish_down'], + 'metadata' => json_decode($row['metadata'] ?: '{}', true), + 'attribs' => json_decode($row['attribs'] ?: '{}', true), + 'images' => json_decode($row['images'] ?: '{}', true), + 'urls' => json_decode($row['urls'] ?: '{}', true), + ]; + } + + return $articles; + } + + /** + * Build menu type payload. + * + * @return array + * + * @since 02.21.00 + */ + private function buildMenuTypePayload(): array + { + $db = $this->db; + $query = $db->getQuery(true) + ->select([ + $db->quoteName('title'), + $db->quoteName('menutype'), + $db->quoteName('description'), + ]) + ->from($db->quoteName('#__menu_types')); + + $db->setQuery($query); + + return $db->loadAssocList() ?: []; + } + + /** + * Build menu item payload with {catid:path} tokens in links. + * + * @return array + * + * @since 02.21.00 + */ + private function buildMenuItemPayload(): array + { + $db = $this->db; + $query = $db->getQuery(true) + ->select([ + $db->quoteName('a.title'), + $db->quoteName('a.alias'), + $db->quoteName('a.menutype'), + $db->quoteName('a.parent_id'), + $db->quoteName('a.link'), + $db->quoteName('a.type'), + $db->quoteName('a.published'), + $db->quoteName('a.access'), + $db->quoteName('a.language'), + $db->quoteName('a.params'), + $db->quoteName('a.home'), + $db->quoteName('a.component_id'), + $db->quoteName('b.alias', 'parent_alias'), + ]) + ->from($db->quoteName('#__menu', 'a')) + ->leftJoin( + $db->quoteName('#__menu', 'b') . ' ON ' + . $db->quoteName('a.parent_id') . ' = ' . $db->quoteName('b.id') + ) + ->where($db->quoteName('a.published') . ' != -2') + ->where($db->quoteName('a.client_id') . ' = 0') + ->where($db->quoteName('a.level') . ' >= 1') + ->order($db->quoteName('a.lft') . ' ASC') + ->setLimit(self::MAX_ITEMS); + + $db->setQuery($query); + $rows = $db->loadAssocList(); + + // Get component name map + $compQuery = $db->getQuery(true) + ->select([$db->quoteName('extension_id'), $db->quoteName('element')]) + ->from($db->quoteName('#__extensions')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')); + $db->setQuery($compQuery); + $components = $db->loadAssocList('extension_id') ?: []; + + $items = []; + + foreach ($rows as $row) + { + $link = $row['link']; + + // Encode category IDs in com_content links as {catid:path} tokens + if (preg_match('/option=com_content/', $link) && preg_match('/&id=(\d+)/', $link, $m)) + { + $catId = (int) $m[1]; + + if (isset($this->catPathMap[$catId])) + { + $link = preg_replace('/&id=\d+/', '&id={catid:' . $this->catPathMap[$catId] . '}', $link); + } + } + + $compName = ''; + + if (!empty($row['component_id']) && isset($components[$row['component_id']])) + { + $compName = $components[$row['component_id']]['element']; + } + + // Root-level items have parent_id=1 (Joomla's root menu item) + $parentAlias = ((int) $row['parent_id'] <= 1) ? '' : ($row['parent_alias'] ?? ''); + + $items[] = [ + 'title' => $row['title'], + 'alias' => $row['alias'], + 'menutype' => $row['menutype'], + 'parent_alias' => $parentAlias, + 'link' => $link, + 'type' => $row['type'], + 'component_name' => $compName, + 'published' => (int) $row['published'], + 'access' => (int) $row['access'], + 'language' => $row['language'], + 'params' => json_decode($row['params'] ?: '{}', true), + 'home' => (int) $row['home'], + ]; + } + + return $items; + } + + /** + * Build module payload with menu assignments. + * + * @return array + * + * @since 02.21.00 + */ + private function buildModulePayload(): array + { + $db = $this->db; + $query = $db->getQuery(true) + ->select([ + $db->quoteName('id'), + $db->quoteName('title'), + $db->quoteName('module'), + $db->quoteName('position'), + $db->quoteName('content'), + $db->quoteName('published'), + $db->quoteName('access'), + $db->quoteName('language'), + $db->quoteName('params'), + $db->quoteName('client_id'), + ]) + ->from($db->quoteName('#__modules')) + ->where($db->quoteName('client_id') . ' = 0') + ->where($db->quoteName('published') . ' != -2') + ->order($db->quoteName('ordering') . ' ASC') + ->setLimit(self::MAX_ITEMS); + + $db->setQuery($query); + $rows = $db->loadAssocList(); + + // Get all module-menu assignments + $mmQuery = $db->getQuery(true) + ->select([ + $db->quoteName('mm.moduleid'), + $db->quoteName('mm.menuid'), + $db->quoteName('m.alias', 'menu_alias'), + $db->quoteName('m.menutype'), + ]) + ->from($db->quoteName('#__modules_menu', 'mm')) + ->leftJoin( + $db->quoteName('#__menu', 'm') . ' ON ' + . $db->quoteName('mm.menuid') . ' = ' . $db->quoteName('m.id') + ); + $db->setQuery($mmQuery); + $allAssignments = $db->loadAssocList(); + + // Group assignments by module ID + $assignmentsByModule = []; + + foreach ($allAssignments as $a) + { + $assignmentsByModule[(int) $a['moduleid']][] = $a; + } + + $modules = []; + + foreach ($rows as $row) + { + $moduleId = (int) $row['id']; + $assignments = $assignmentsByModule[$moduleId] ?? []; + + // Determine assignment type: 0 = all pages, positive = selected, negative = excluded + $menuAliases = []; + $assignType = 0; + + if (!empty($assignments)) + { + $firstMenuId = (int) $assignments[0]['menuid']; + + if ($firstMenuId === 0) + { + $assignType = 0; // All pages + } + elseif ($firstMenuId < 0) + { + $assignType = -1; // All except selected + foreach ($assignments as $a) + { + if (!empty($a['menu_alias'])) + { + $menuAliases[] = $a['menutype'] . ':' . $a['menu_alias']; + } + } + } + else + { + $assignType = 1; // Selected only + foreach ($assignments as $a) + { + if (!empty($a['menu_alias'])) + { + $menuAliases[] = $a['menutype'] . ':' . $a['menu_alias']; + } + } + } + } + + $modules[] = [ + 'title' => $row['title'], + 'module' => $row['module'], + 'position' => $row['position'], + 'content' => $row['content'], + 'published' => (int) $row['published'], + 'access' => (int) $row['access'], + 'language' => $row['language'], + 'params' => json_decode($row['params'] ?: '{}', true), + 'client_id' => (int) $row['client_id'], + 'menu_assignment' => [ + 'assignment' => $assignType, + 'menu_item_aliases' => $menuAliases, + ], + ]; + } + + return $modules; + } +} diff --git a/src/packages/plg_system_mokowaas/forms/sync_target_entry.xml b/src/packages/plg_system_mokowaas/forms/sync_target_entry.xml new file mode 100644 index 00000000..301bc304 --- /dev/null +++ b/src/packages/plg_system_mokowaas/forms/sync_target_entry.xml @@ -0,0 +1,15 @@ + +
+ + + + diff --git a/src/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.ini b/src/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.ini index 81098e29..b3d776e7 100644 --- a/src/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.ini +++ b/src/packages/plg_system_mokowaas/language/en-GB/plg_system_mokowaas.ini @@ -99,6 +99,20 @@ PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_DESC="Block installing extensions from U PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_LABEL="Hidden Menu Items" PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_DESC="Components to hide from admin menu for non-master users. One per line (e.g., com_installer)." +; ===== Content Sync fieldset ===== +PLG_SYSTEM_MOKOWAAS_FIELDSET_SYNC_LABEL="Content Sync" +PLG_SYSTEM_MOKOWAAS_FIELDSET_SYNC_DESC="One-way content push to remote MokoWaaS sites. Syncs articles, categories, menus, and modules by alias." +PLG_SYSTEM_MOKOWAAS_SYNC_TARGETS_LABEL="Sync Targets" +PLG_SYSTEM_MOKOWAAS_SYNC_TARGETS_DESC="Remote sites to push content to. Each target requires the site URL and that site's health API token." +PLG_SYSTEM_MOKOWAAS_SYNC_PUSH_NOW_LABEL="Push Content Now" +PLG_SYSTEM_MOKOWAAS_SYNC_PUSH_NOW_DESC="Set to Yes and save to immediately push all content to all configured targets. Resets to No automatically." +PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_URL_LABEL="Site URL" +PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_URL_DESC="Full URL of the remote Joomla site (e.g. https://client.example.com)." +PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_TOKEN_LABEL="API Token" +PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_TOKEN_DESC="The health_api_token from the remote site's MokoWaaS plugin settings." +PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_LABEL_LABEL="Label" +PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_LABEL_DESC="Friendly name for this target (for identification only)." + ; ===== Diagnostics fieldset ===== PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_LABEL="Diagnostics & Monitoring" PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_DESC="Health check endpoint for external monitoring systems (e.g. Grafana). Exposes system status via a token-authenticated JSON API." diff --git a/src/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.ini b/src/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.ini index b26a7b6a..b39f1921 100644 --- a/src/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.ini +++ b/src/packages/plg_system_mokowaas/language/en-US/plg_system_mokowaas.ini @@ -99,6 +99,20 @@ PLG_SYSTEM_MOKOWAAS_DISABLE_INSTALL_URL_DESC="Block installing extensions from U PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_LABEL="Hidden Menu Items" PLG_SYSTEM_MOKOWAAS_HIDDEN_MENUS_DESC="Components to hide from admin menu for non-master users. One per line (e.g., com_installer)." +; ===== Content Sync fieldset ===== +PLG_SYSTEM_MOKOWAAS_FIELDSET_SYNC_LABEL="Content Sync" +PLG_SYSTEM_MOKOWAAS_FIELDSET_SYNC_DESC="One-way content push to remote MokoWaaS sites. Syncs articles, categories, menus, and modules by alias." +PLG_SYSTEM_MOKOWAAS_SYNC_TARGETS_LABEL="Sync Targets" +PLG_SYSTEM_MOKOWAAS_SYNC_TARGETS_DESC="Remote sites to push content to. Each target requires the site URL and that site's health API token." +PLG_SYSTEM_MOKOWAAS_SYNC_PUSH_NOW_LABEL="Push Content Now" +PLG_SYSTEM_MOKOWAAS_SYNC_PUSH_NOW_DESC="Set to Yes and save to immediately push all content to all configured targets. Resets to No automatically." +PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_URL_LABEL="Site URL" +PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_URL_DESC="Full URL of the remote Joomla site (e.g. https://client.example.com)." +PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_TOKEN_LABEL="API Token" +PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_TOKEN_DESC="The health_api_token from the remote site's MokoWaaS plugin settings." +PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_LABEL_LABEL="Label" +PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_LABEL_DESC="Friendly name for this target (for identification only)." + ; ===== Diagnostics fieldset ===== PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_LABEL="Diagnostics & Monitoring" PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_DESC="Health check endpoint for external monitoring systems (e.g. Grafana). Exposes system status via a token-authenticated JSON API." diff --git a/src/packages/plg_system_mokowaas/mokowaas.xml b/src/packages/plg_system_mokowaas/mokowaas.xml index 6e06856b..961662a4 100644 --- a/src/packages/plg_system_mokowaas/mokowaas.xml +++ b/src/packages/plg_system_mokowaas/mokowaas.xml @@ -350,7 +350,30 @@ buttons="add,remove,move" /> -
+ + + + + +
+
diff --git a/src/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php b/src/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php index 74523396..8acb7b60 100644 --- a/src/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php +++ b/src/packages/plg_webservices_mokowaas/src/Extension/MokoWaaSApi.php @@ -82,5 +82,17 @@ final class MokoWaaSApi extends CMSPlugin implements SubscriberInterface 'snapshot', ['component' => 'com_mokowaas'] ); + + $router->createCRUDRoutes( + 'v1/mokowaas/sync', + 'sync', + ['component' => 'com_mokowaas'] + ); + + $router->createCRUDRoutes( + 'v1/mokowaas/sync-receive', + 'syncreceive', + ['component' => 'com_mokowaas'] + ); } } diff --git a/wiki/api-endpoints.md b/wiki/api-endpoints.md index aeab2a15..afa13f0f 100644 --- a/wiki/api-endpoints.md +++ b/wiki/api-endpoints.md @@ -180,6 +180,8 @@ In addition to the query-string endpoints above, MokoWaaS registers standard Joo | `POST /api/v1/mokowaas/install` | InstallController | Install extension from ZIP URL | | `POST /api/v1/mokowaas/reset` | ResetController | Restore site to baseline snapshot | | `GET/POST /api/v1/mokowaas/snapshot` | SnapshotController | List or create snapshots | +| `POST /api/v1/mokowaas/sync` | SyncController | Push content to all sync targets | +| `POST /api/v1/mokowaas/sync-receive` | SyncReceiveController | Receive content from source site | These routes use Joomla's standard API authentication (API token in `X-Joomla-Token` header) and are useful for integrations that already use the Joomla API framework.