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 [ 'mokosuiteclient_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, '/') . '/?mokosuiteclient=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, 'mokosuiteclient' ); 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; } }