From 9e257d16ed78d3ab9bb4a97e1fdd4a52a29a96a0 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 21 Apr 2026 12:31:50 -0500 Subject: [PATCH] Bridge: rewrite as rename-in-place + DB update (no external downloads) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename templates/mokocassiopeia → mokoonyx - Rename media/templates/site/mokocassiopeia → mokoonyx - Update #__extensions element and name - Update #__template_styles template, title, and params - Update #__menu link references - Update #__update_sites to point to MokoOnyx updates.xml - Clear #__updates cached entries for old extension - No HTTP requests, no ZIP downloads, no Installer conflicts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/helper/bridge.php | 458 ++++++++++++++---------------------------- 1 file changed, 153 insertions(+), 305 deletions(-) diff --git a/src/helper/bridge.php b/src/helper/bridge.php index 9172027..34f14fe 100644 --- a/src/helper/bridge.php +++ b/src/helper/bridge.php @@ -10,14 +10,13 @@ /** * Bridge migration helper — MokoCassiopeia → MokoOnyx * - * Downloads and installs MokoOnyx from the Gitea release, then migrates - * template styles and menu assignments from MokoCassiopeia. + * Renames template files/folders and updates the database to migrate + * from MokoCassiopeia to MokoOnyx. No external downloads required. */ defined('_JEXEC') or die; use Joomla\CMS\Factory; -use Joomla\CMS\Installer\Installer; use Joomla\CMS\Log\Log; class MokoBridgeMigration @@ -28,12 +27,6 @@ class MokoBridgeMigration private const OLD_DISPLAY = 'MokoCassiopeia'; private const NEW_DISPLAY = 'MokoOnyx'; - /** Raw URL for MokoOnyx updates.xml on main — used to discover the stable download URL */ - private const UPDATES_XML_URL = 'https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/main/updates.xml'; - - /** Fallback URL if updates.xml cannot be parsed */ - private const FALLBACK_URL = 'https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/v01/mokoonyx-01.00.00.zip'; - /** * Run the full migration. */ @@ -41,45 +34,32 @@ class MokoBridgeMigration { $app = Factory::getApplication(); - // Check if MokoOnyx is already installed + // Already migrated? if (is_dir(JPATH_ROOT . '/templates/' . self::NEW_NAME)) { - self::log('MokoOnyx already installed — skipping download.'); - self::migrateStyles(); + self::log('MokoOnyx template dir already exists — updating database only.'); + self::updateDatabase(); 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) { + // 1. Rename template directory + $renamed = self::renameTemplateDir(); + if (!$renamed) { $app->enqueueMessage( - 'MokoOnyx migration: automatic installation failed. ' - . 'Please install MokoOnyx manually from ' - . 'Gitea Releases.', + 'MokoOnyx migration: could not rename template directory. ' + . 'Please rename templates/mokocassiopeia to templates/mokoonyx manually.', 'warning' ); return false; } - // 3. Copy user files (custom themes, user.css, user.js) - self::copyAndRename(); + // 2. Rename media directory + self::renameMediaDir(); - // 4. Migrate template styles and params - self::migrateStyles(); + // 3. Update database (extensions, template_styles, menu assignments) + self::updateDatabase(); - // 5. Notify admin + // 4. Notify admin self::notifyUser($app); self::log('Bridge migration completed successfully.'); @@ -87,308 +67,176 @@ class MokoBridgeMigration } /** - * Download the MokoOnyx ZIP to Joomla's tmp directory. - * - * Reads MokoOnyx's updates.xml on main to discover the current stable - * download URL, falling back to a hardcoded URL if parsing fails. + * Rename templates/mokocassiopeia → templates/mokoonyx */ - private static function downloadRelease(): ?string + private static function renameTemplateDir(): bool { - $tmpDir = Factory::getApplication()->get('tmp_path', JPATH_ROOT . '/tmp'); - $zipPath = $tmpDir . '/mokoonyx-install.zip'; + $oldDir = JPATH_ROOT . '/templates/' . self::OLD_NAME; + $newDir = JPATH_ROOT . '/templates/' . self::NEW_NAME; - // 1. Discover the stable download URL from MokoOnyx's updates.xml - $releaseUrl = self::discoverStableUrl(); - if (!$releaseUrl) { - self::log('Bridge: could not discover release URL from updates.xml, using fallback'); - $releaseUrl = self::FALLBACK_URL; - } - - self::log('Bridge: downloading MokoOnyx from ' . $releaseUrl); - - // 2. Download the ZIP - $content = self::httpGet($releaseUrl); - - if ($content === false || strlen($content) < 1000) { - self::log('Bridge: failed to download MokoOnyx ZIP from ' . $releaseUrl, '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; - } - - /** - * Fetch MokoOnyx's updates.xml and extract the stable channel ZIP URL. - * - * Always targets the stable channel — the bridge should only install - * production-ready builds of MokoOnyx. - */ - private static function discoverStableUrl(): ?string - { - $xml = self::httpGet(self::UPDATES_XML_URL); - if ($xml === false || strlen($xml) < 100) { - self::log('Bridge: failed to fetch MokoOnyx updates.xml', 'warning'); - return null; - } - - libxml_use_internal_errors(true); - $doc = simplexml_load_string($xml); - libxml_clear_errors(); - - if (!$doc) { - self::log('Bridge: failed to parse MokoOnyx updates.xml', 'warning'); - return null; - } - - // Find the stable block - foreach ($doc->update as $update) { - $tags = $update->tags->tag ?? []; - foreach ($tags as $tag) { - if ((string) $tag === 'stable') { - foreach ($update->downloads->downloadurl as $dl) { - $format = (string) ($dl['format'] ?? ''); - $url = trim((string) $dl); - if ($format === 'zip' && !empty($url)) { - self::log('Bridge: discovered stable URL: ' . $url); - return $url; - } - } - } - } - } - - self::log('Bridge: no stable ZIP URL found in MokoOnyx updates.xml', 'warning'); - return null; - } - - /** - * HTTP GET helper — tries file_get_contents then cURL. - * - * @return string|false Response body or false on failure. - */ - private static function httpGet(string $url) - { - $content = false; - - 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($url, false, $ctx); - } - - if ($content === false && function_exists('curl_init')) { - $ch = curl_init($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; - } - } - - return $content; - } - - /** - * 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'); + if (!is_dir($oldDir)) { + self::log('Bridge: old template dir not found: ' . $oldDir, 'warning'); return false; } + + if (is_dir($newDir)) { + self::log('Bridge: new template dir already exists — skipping rename'); + return true; + } + + $result = @rename($oldDir, $newDir); + if ($result) { + self::log('Bridge: renamed template dir to ' . self::NEW_NAME); + } else { + self::log('Bridge: failed to rename template dir', 'error'); + } + + return $result; } /** - * Migrate template styles and menu assignments from MokoCassiopeia to MokoOnyx. + * Rename media/templates/site/mokocassiopeia → 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 + private static function renameMediaDir(): void { $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; + if (!is_dir($oldMedia)) { + self::log('Bridge: old media dir not found — skipping'); + return; } - $copied = 0; + if (is_dir($newMedia)) { + self::log('Bridge: new media dir already exists — skipping rename'); + return; + } - // 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', - ]; + if (@rename($oldMedia, $newMedia)) { + self::log('Bridge: renamed media dir to ' . self::NEW_NAME); + } else { + self::log('Bridge: failed to rename media dir', 'warning'); + } + } - 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++; + /** + * Update all database references from mokocassiopeia → mokoonyx. + */ + private static function updateDatabase(): void + { + $db = Factory::getDbo(); + + // 1. Update #__extensions — change element and name + $query = $db->getQuery(true) + ->update('#__extensions') + ->set($db->quoteName('element') . ' = ' . $db->quote(self::NEW_NAME)) + ->set($db->quoteName('name') . ' = ' . $db->quote(self::NEW_NAME)) + ->where($db->quoteName('element') . ' = ' . $db->quote(self::OLD_NAME)) + ->where($db->quoteName('type') . ' = ' . $db->quote('template')); + try { + $db->setQuery($query)->execute(); + $affected = $db->getAffectedRows(); + if ($affected > 0) { + self::log("Bridge: updated {$affected} row(s) in #__extensions"); + } + } catch (\Throwable $e) { + self::log('Bridge: #__extensions update failed: ' . $e->getMessage(), 'error'); + } + + // 2. Update #__template_styles — rename template and title + $query = $db->getQuery(true) + ->select('*') + ->from('#__template_styles') + ->where($db->quoteName('template') . ' = ' . $db->quote(self::OLD_NAME)); + $styles = $db->setQuery($query)->loadObjectList(); + + foreach ($styles as $style) { + $newTitle = str_replace(self::OLD_DISPLAY, self::NEW_DISPLAY, $style->title); + // Also catch lowercase variant + $newTitle = str_replace(self::OLD_NAME, self::NEW_NAME, $newTitle); + + $newParams = $style->params; + if (is_string($newParams)) { + $newParams = str_replace(self::OLD_NAME, self::NEW_NAME, $newParams); + } + + $update = $db->getQuery(true) + ->update('#__template_styles') + ->set($db->quoteName('template') . ' = ' . $db->quote(self::NEW_NAME)) + ->set($db->quoteName('title') . ' = ' . $db->quote($newTitle)) + ->set($db->quoteName('params') . ' = ' . $db->quote($newParams)) + ->where('id = ' . (int) $style->id); + + try { + $db->setQuery($update)->execute(); + } catch (\Throwable $e) { + self::log('Bridge: style update failed for id=' . $style->id . ': ' . $e->getMessage(), 'error'); } } - // Copy favicon directory - $faviconSrc = JPATH_ROOT . '/images/favicons'; - if (is_dir($faviconSrc)) { - self::log('Bridge: favicons already at images/favicons — shared between templates'); + if (!empty($styles)) { + self::log('Bridge: updated ' . count($styles) . ' template style(s) in #__template_styles'); } - self::log("Bridge: copied {$copied} user file(s) to MokoOnyx"); - return true; + // 3. Update #__menu — fix template_style_id link field references + // Menu items store the template name in the link for template-specific assignments + try { + $query = $db->getQuery(true) + ->update('#__menu') + ->set($db->quoteName('link') . ' = REPLACE(' . $db->quoteName('link') . ', ' + . $db->quote(self::OLD_NAME) . ', ' . $db->quote(self::NEW_NAME) . ')') + ->where($db->quoteName('link') . ' LIKE ' . $db->quote('%' . self::OLD_NAME . '%')); + $db->setQuery($query)->execute(); + $affected = $db->getAffectedRows(); + if ($affected > 0) { + self::log("Bridge: updated {$affected} menu link(s)"); + } + } catch (\Throwable $e) { + self::log('Bridge: #__menu update failed: ' . $e->getMessage(), 'warning'); + } + + // 4. Update #__update_sites — point to MokoOnyx updates.xml + try { + $newLocation = 'https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/main/updates.xml'; + $query = $db->getQuery(true) + ->update('#__update_sites') + ->set($db->quoteName('location') . ' = ' . $db->quote($newLocation)) + ->set($db->quoteName('name') . ' = ' . $db->quote(self::NEW_DISPLAY)) + ->where($db->quoteName('location') . ' LIKE ' . $db->quote('%MokoCassiopeia%')); + $db->setQuery($query)->execute(); + $affected = $db->getAffectedRows(); + if ($affected > 0) { + self::log("Bridge: updated {$affected} update site(s) to MokoOnyx"); + } + } catch (\Throwable $e) { + self::log('Bridge: #__update_sites update failed: ' . $e->getMessage(), 'warning'); + } + + // 5. Update #__updates — clear cached updates for old extension + try { + $query = $db->getQuery(true) + ->delete('#__updates') + ->where($db->quoteName('element') . ' = ' . $db->quote(self::OLD_NAME)); + $db->setQuery($query)->execute(); + $affected = $db->getAffectedRows(); + if ($affected > 0) { + self::log("Bridge: cleared {$affected} cached update(s) for old extension"); + } + } catch (\Throwable $e) { + self::log('Bridge: #__updates cleanup failed: ' . $e->getMessage(), 'warning'); + } } 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.', + . 'Your template files, settings, and menu assignments have been migrated automatically. ' + . 'MokoOnyx is now your active site template.', '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 = [