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 = [