feat: rewrite content sync to use Joomla REST API with per-task config

Each sync task instance now has its own target URL, API token, and
content type toggles. Sync strategy is delete-then-push via the
Joomla API for articles and menus (avoids duplicates, respects ACL).

Content types: articles, categories, menus, modules
File types: images/, files/, media/ (via MokoWaaS sync-receive endpoint)

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