feat(sync): add one-way content sync — push articles, menus, modules to remote sites
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Update Server / Update Server (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Update Server / Update Server (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\Registry\Registry;
|
||||
|
||||
/**
|
||||
* Content sync trigger API controller (sender side).
|
||||
*
|
||||
* POST /api/index.php/v1/mokowaas/sync
|
||||
*
|
||||
* Pushes content to all configured sync targets.
|
||||
*
|
||||
* @since 02.21.00
|
||||
*/
|
||||
class SyncController extends BaseController
|
||||
{
|
||||
public function execute(): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
|
||||
if ($app->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage com_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Moko\Component\MokoWaaS\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
|
||||
/**
|
||||
* Content sync receiver API controller (target side).
|
||||
*
|
||||
* POST /api/index.php/v1/mokowaas/sync-receive
|
||||
*
|
||||
* Accepts a JSON payload from a source site and applies the content locally.
|
||||
*
|
||||
* @since 02.21.00
|
||||
*/
|
||||
class SyncReceiveController extends BaseController
|
||||
{
|
||||
public function execute(): void
|
||||
{
|
||||
$app = Factory::getApplication();
|
||||
|
||||
if ($app->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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,819 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage plg_system_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
* PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncReceiver.php
|
||||
* VERSION: 02.21.00
|
||||
* BRIEF: Receiver-side content sync — applies incoming payload to local DB
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\System\MokoWaaS\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
|
||||
/**
|
||||
* Content Sync Receiver — applies incoming sync payload to the local site.
|
||||
*
|
||||
* Processes categories, articles, menu types, menu items, and modules
|
||||
* from a JSON payload sent by a source MokoWaaS site. Content is matched
|
||||
* by alias (upsert pattern): existing content is updated, new content
|
||||
* is inserted.
|
||||
*
|
||||
* @since 02.21.00
|
||||
*/
|
||||
class ContentSyncReceiver
|
||||
{
|
||||
/**
|
||||
* @var \Joomla\Database\DatabaseInterface
|
||||
* @since 02.21.00
|
||||
*/
|
||||
private $db;
|
||||
|
||||
/**
|
||||
* Warnings collected during sync.
|
||||
*
|
||||
* @var array
|
||||
* @since 02.21.00
|
||||
*/
|
||||
private array $warnings = [];
|
||||
|
||||
/**
|
||||
* Cache of resolved category paths → local IDs.
|
||||
*
|
||||
* @var array
|
||||
* @since 02.21.00
|
||||
*/
|
||||
private array $catPathCache = [];
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param \Joomla\Database\DatabaseInterface|null $db Database driver
|
||||
*
|
||||
* @since 02.21.00
|
||||
*/
|
||||
public function __construct($db = null)
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoWaaS
|
||||
* @subpackage plg_system_mokowaas
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: Joomla.Plugin
|
||||
* INGROUP: MokoWaaS
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS
|
||||
* PATH: /src/packages/plg_system_mokowaas/Service/ContentSyncService.php
|
||||
* VERSION: 02.21.00
|
||||
* BRIEF: Sender-side content sync — builds payload and pushes to remote sites
|
||||
*/
|
||||
|
||||
namespace Moko\Plugin\System\MokoWaaS\Service;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
|
||||
/**
|
||||
* Content Sync Service — builds a JSON payload of site content and pushes
|
||||
* it to one or more remote MokoWaaS sites.
|
||||
*
|
||||
* Content is matched by alias on the receiving end (upsert-by-alias).
|
||||
* Category IDs in menu item links are encoded as {catid:alias/path} tokens
|
||||
* so the receiver can resolve them to local IDs.
|
||||
*
|
||||
* @since 02.21.00
|
||||
*/
|
||||
class ContentSyncService
|
||||
{
|
||||
/**
|
||||
* Maximum items per content type to prevent unbounded memory.
|
||||
*
|
||||
* @var int
|
||||
* @since 02.21.00
|
||||
*/
|
||||
private const MAX_ITEMS = 2000;
|
||||
|
||||
/**
|
||||
* HTTP timeout for push requests in seconds.
|
||||
*
|
||||
* @var int
|
||||
* @since 02.21.00
|
||||
*/
|
||||
private const HTTP_TIMEOUT = 60;
|
||||
|
||||
/**
|
||||
* @var \Joomla\Database\DatabaseInterface
|
||||
* @since 02.21.00
|
||||
*/
|
||||
private $db;
|
||||
|
||||
/**
|
||||
* Category ID → alias path map cache.
|
||||
*
|
||||
* @var array
|
||||
* @since 02.21.00
|
||||
*/
|
||||
private array $catPathMap = [];
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param \Joomla\Database\DatabaseInterface|null $db Database driver
|
||||
*
|
||||
* @since 02.21.00
|
||||
*/
|
||||
public function __construct($db = null)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<form>
|
||||
<field name="url" type="url"
|
||||
label="PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_URL_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_URL_DESC"
|
||||
required="true" hint="https://client.example.com" />
|
||||
<field name="token" type="text"
|
||||
label="PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_TOKEN_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_TOKEN_DESC"
|
||||
required="true" hint="health_api_token from target site" />
|
||||
<field name="label" type="text"
|
||||
label="PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_LABEL_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_SYNC_TARGET_LABEL_DESC"
|
||||
hint="e.g. Client A" />
|
||||
</form>
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -350,7 +350,30 @@
|
||||
buttons="add,remove,move"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset name="diagnostics"
|
||||
<fieldset name="content_sync"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_SYNC_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_SYNC_DESC"
|
||||
>
|
||||
<field
|
||||
name="sync_targets"
|
||||
type="subform"
|
||||
label="PLG_SYSTEM_MOKOWAAS_SYNC_TARGETS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_SYNC_TARGETS_DESC"
|
||||
formsource="plugins/system/mokowaas/forms/sync_target_entry.xml"
|
||||
multiple="true"
|
||||
layout="joomla.form.field.subform.repeatable-table"
|
||||
groupByFieldset="false"
|
||||
buttons="add,remove,move"
|
||||
/>
|
||||
<field name="sync_push_now" type="radio" default="0"
|
||||
label="PLG_SYSTEM_MOKOWAAS_SYNC_PUSH_NOW_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_SYNC_PUSH_NOW_DESC"
|
||||
class="btn-group btn-group-yesno">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
<fieldset name="diagnostics"
|
||||
label="PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_LABEL"
|
||||
description="PLG_SYSTEM_MOKOWAAS_FIELDSET_DIAGNOSTICS_DESC"
|
||||
>
|
||||
|
||||
@@ -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']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user