* * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later */ /** * Bridge migration helper — MokoCassiopeia → MokoOnyx * * Downloads and installs MokoOnyx from the Gitea release, then migrates * template styles and menu assignments from MokoCassiopeia. */ defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\Installer\Installer; use Joomla\CMS\Log\Log; class MokoBridgeMigration { private const OLD_NAME = 'mokocassiopeia'; private const NEW_NAME = 'mokoonyx'; private const OLD_DISPLAY = 'MokoCassiopeia'; private const NEW_DISPLAY = 'MokoOnyx'; /** URL to the latest MokoOnyx stable release ZIP */ private const RELEASE_URL = 'https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/v01/mokoonyx-01.00.00.zip'; /** * Run the full migration. */ public static function run(): bool { $app = Factory::getApplication(); // Check if MokoOnyx is already installed if (is_dir(JPATH_ROOT . '/templates/' . self::NEW_NAME)) { self::log('MokoOnyx already installed — skipping download.'); self::migrateStyles(); self::notifyUser($app); return true; } // 1. Try downloading and installing MokoOnyx from Gitea release $installed = false; $zipPath = self::downloadRelease(); if ($zipPath) { $installed = self::installPackage($zipPath); @unlink($zipPath); } // 2. Fallback: copy from MokoCassiopeia and rename if (!$installed) { self::log('Bridge: download/install failed, falling back to file copy'); $installed = self::copyAndRename(); } if (!$installed) { $app->enqueueMessage( 'MokoOnyx migration: automatic installation failed. ' . 'Please install MokoOnyx manually from ' . 'Gitea Releases.', 'warning' ); return false; } // 3. Copy user files (custom themes, user.css, user.js) self::copyAndRename(); // 4. Migrate template styles and params self::migrateStyles(); // 5. Notify admin self::notifyUser($app); self::log('Bridge migration completed successfully.'); return true; } /** * Download the MokoOnyx ZIP to Joomla's tmp directory. */ private static function downloadRelease(): ?string { $tmpDir = Factory::getApplication()->get('tmp_path', JPATH_ROOT . '/tmp'); $zipPath = $tmpDir . '/mokoonyx-install.zip'; $content = false; // Method 1: file_get_contents if (ini_get('allow_url_fopen')) { $ctx = stream_context_create([ 'http' => [ 'timeout' => 60, 'follow_location' => true, 'max_redirects' => 5, ], 'ssl' => [ 'verify_peer' => true, 'verify_peer_name' => true, ], ]); $content = @file_get_contents(self::RELEASE_URL, false, $ctx); } // Method 2: cURL if ($content === false && function_exists('curl_init')) { $ch = curl_init(self::RELEASE_URL); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5, CURLOPT_TIMEOUT => 60, CURLOPT_SSL_VERIFYPEER => true, ]); $content = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { $content = false; } } if ($content === false || strlen($content) < 1000) { self::log('Bridge: failed to download MokoOnyx ZIP from ' . self::RELEASE_URL, 'error'); return null; } if (file_put_contents($zipPath, $content) === false) { self::log('Bridge: failed to write ZIP to ' . $zipPath, 'error'); return null; } self::log('Bridge: downloaded MokoOnyx ZIP (' . strlen($content) . ' bytes)'); return $zipPath; } /** * Install the downloaded ZIP via Joomla's Installer. */ private static function installPackage(string $zipPath): bool { try { $installer = Installer::getInstance(); $tmpDir = Factory::getApplication()->get('tmp_path', JPATH_ROOT . '/tmp'); $extractDir = $tmpDir . '/mokoonyx_install_' . time(); $zip = new \ZipArchive(); if ($zip->open($zipPath) !== true) { self::log('Bridge: failed to open ZIP', 'error'); return false; } $zip->extractTo($extractDir); $zip->close(); $result = $installer->install($extractDir); if (is_dir($extractDir)) { self::removeDirectory($extractDir); } if ($result) { self::log('Bridge: MokoOnyx installed via Joomla Installer'); } else { self::log('Bridge: Joomla Installer returned false', 'error'); } return (bool) $result; } catch (\Throwable $e) { self::log('Bridge: install failed: ' . $e->getMessage(), 'error'); return false; } } /** * Migrate template styles and menu assignments from MokoCassiopeia to MokoOnyx. */ private static function migrateStyles(): void { $db = Factory::getDbo(); $query = $db->getQuery(true) ->select('*') ->from('#__template_styles') ->where($db->quoteName('template') . ' = ' . $db->quote(self::OLD_NAME)) ->where($db->quoteName('client_id') . ' = 0'); $oldStyles = $db->setQuery($query)->loadObjectList(); if (empty($oldStyles)) { self::log('No MokoCassiopeia styles found — nothing to migrate.'); return; } foreach ($oldStyles as $oldStyle) { $newTitle = str_replace(self::OLD_DISPLAY, self::NEW_DISPLAY, $oldStyle->title); $query = $db->getQuery(true) ->select('COUNT(*)') ->from('#__template_styles') ->where($db->quoteName('template') . ' = ' . $db->quote(self::NEW_NAME)) ->where($db->quoteName('title') . ' = ' . $db->quote($newTitle)); if ((int) $db->setQuery($query)->loadResult() > 0) { continue; } $newStyle = clone $oldStyle; unset($newStyle->id); $newStyle->template = self::NEW_NAME; $newStyle->title = $newTitle; if (is_string($newStyle->params)) { $newStyle->params = str_replace(self::OLD_NAME, self::NEW_NAME, $newStyle->params); } $db->insertObject('#__template_styles', $newStyle, 'id'); $newId = $newStyle->id; if ($oldStyle->home == 1) { $db->setQuery( $db->getQuery(true) ->update('#__template_styles') ->set($db->quoteName('home') . ' = 1') ->where('id = ' . (int) $newId) )->execute(); $db->setQuery( $db->getQuery(true) ->update('#__template_styles') ->set($db->quoteName('home') . ' = 0') ->where('id = ' . (int) $oldStyle->id) )->execute(); self::log('Set MokoOnyx as default site template.'); } } self::log('Migrated ' . count($oldStyles) . ' template style(s).'); } /** * Copy user-specific files from MokoCassiopeia to MokoOnyx. * Only copies custom themes, user.css, and user.js — not the full template. * MokoOnyx must already be installed (via download or manual). */ private static function copyAndRename(): bool { $oldMedia = JPATH_ROOT . '/media/templates/site/' . self::OLD_NAME; $newMedia = JPATH_ROOT . '/media/templates/site/' . self::NEW_NAME; if (!is_dir($newMedia)) { self::log('Bridge: MokoOnyx media dir not found — cannot copy user files', 'warning'); return false; } $copied = 0; // Copy custom theme palettes $userFiles = [ 'css/theme/light.custom.css', 'css/theme/dark.custom.css', 'css/theme/light.custom.min.css', 'css/theme/dark.custom.min.css', 'css/user.css', 'css/user.min.css', 'js/user.js', 'js/user.min.js', ]; foreach ($userFiles as $relPath) { $srcFile = $oldMedia . '/' . $relPath; $dstFile = $newMedia . '/' . $relPath; if (is_file($srcFile) && !is_file($dstFile)) { $dstDir = dirname($dstFile); if (!is_dir($dstDir)) { mkdir($dstDir, 0755, true); } copy($srcFile, $dstFile); $copied++; } } // Copy favicon directory $faviconSrc = JPATH_ROOT . '/images/favicons'; if (is_dir($faviconSrc)) { self::log('Bridge: favicons already at images/favicons — shared between templates'); } self::log("Bridge: copied {$copied} user file(s) to MokoOnyx"); return true; } private static function notifyUser($app): void { $app->enqueueMessage( 'MokoCassiopeia has been renamed to MokoOnyx.
' . 'Your template settings have been migrated automatically. ' . 'MokoOnyx is now your active site template. ' . 'You can safely uninstall MokoCassiopeia from Extensions → Manage.', 'success' ); } private static function removeDirectory(string $dir): void { $items = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ($items as $item) { $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); } rmdir($dir); } private static function log(string $message, string $priority = 'info'): void { $priorities = [ 'info' => Log::INFO, 'warning' => Log::WARNING, 'error' => Log::ERROR, ]; Log::addLogger( ['text_file' => 'mokocassiopeia_bridge.log.php'], Log::ALL, ['mokocassiopeia_bridge'] ); Log::add($message, $priorities[$priority] ?? Log::INFO, 'mokocassiopeia_bridge'); } }