diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml
index 131ecab..4eb0ee2 100644
--- a/.github/workflows/auto-release.yml
+++ b/.github/workflows/auto-release.yml
@@ -517,9 +517,9 @@ jobs:
# Replace downloads block with both formats + SHA
sed -i "s|.*|\n ${ZIP_URL}\n ${TAR_URL}\n |" updates.xml 2>/dev/null || true
if grep -q '' updates.xml; then
- sed -i "s|.*|sha256:${SHA256_ZIP}|" updates.xml
+ sed -i "s|.*|${SHA256_ZIP}|" updates.xml
else
- sed -i "s||\n sha256:${SHA256_ZIP}|" updates.xml
+ sed -i "s||\n ${SHA256_ZIP}|" updates.xml
fi
git add updates.xml
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index bd051f1..7c0dc93 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -333,7 +333,7 @@ jobs:
block = re.sub(r"[^<]*", f"{date}", block)
# Update SHA-256
- block = re.sub(r"[^<]*", f"sha256:{sha256}", block)
+ block = re.sub(r"[^<]*", f"{sha256}", block)
# Update Gitea download URL
gitea_url = f"https://git.mokoconsulting.tech/{gitea_org}/{gitea_repo}/releases/download/{tag}/{zip_name}"
diff --git a/src/helper/bridge.php b/src/helper/bridge.php
new file mode 100644
index 0000000..71d2546
--- /dev/null
+++ b/src/helper/bridge.php
@@ -0,0 +1,360 @@
+
+ *
+ * This file is part of a Moko Consulting project.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+/**
+ * Bridge migration helper — MokoCassiopeia → MokoOnyx
+ *
+ * Called from script.php during the v03.10.00 update. Copies the template
+ * to the new directory name, migrates database records, and sets MokoOnyx
+ * as the active site template.
+ */
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Factory;
+use Joomla\CMS\Filesystem\File;
+use Joomla\CMS\Filesystem\Folder;
+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';
+
+ /**
+ * Run the full migration.
+ *
+ * @return bool True on success, false on failure.
+ */
+ public static function run(): bool
+ {
+ $app = Factory::getApplication();
+ $db = Factory::getDbo();
+
+ // 1. Copy template files
+ if (!self::copyTemplateFiles()) {
+ $app->enqueueMessage(
+ 'MokoOnyx migration: failed to copy template files. '
+ . 'You can manually copy templates/mokocassiopeia to templates/mokoonyx.',
+ 'error'
+ );
+ return false;
+ }
+
+ // 2. Copy media files
+ if (!self::copyMediaFiles()) {
+ $app->enqueueMessage(
+ 'MokoOnyx migration: failed to copy media files. '
+ . 'You can manually copy media/templates/site/mokocassiopeia to media/templates/site/mokoonyx.',
+ 'warning'
+ );
+ }
+
+ // 3. Rename internals in the new copy (templateDetails.xml, language files, etc.)
+ self::renameInternals();
+
+ // 4. Register the new template in the database
+ self::migrateDatabase($db);
+
+ // 5. Notify the admin
+ $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'
+ );
+
+ self::log('Bridge migration completed successfully.');
+ return true;
+ }
+
+ /**
+ * Copy template directory.
+ */
+ private static function copyTemplateFiles(): bool
+ {
+ $src = JPATH_ROOT . '/templates/' . self::OLD_NAME;
+ $dst = JPATH_ROOT . '/templates/' . self::NEW_NAME;
+
+ if (is_dir($dst)) {
+ self::log('MokoOnyx template directory already exists — skipping copy.');
+ return true;
+ }
+
+ if (!is_dir($src)) {
+ self::log('Source template directory not found: ' . $src, 'error');
+ return false;
+ }
+
+ return Folder::copy($src, $dst);
+ }
+
+ /**
+ * Copy media directory.
+ */
+ private static function copyMediaFiles(): bool
+ {
+ $src = JPATH_ROOT . '/media/templates/site/' . self::OLD_NAME;
+ $dst = JPATH_ROOT . '/media/templates/site/' . self::NEW_NAME;
+
+ if (is_dir($dst)) {
+ self::log('MokoOnyx media directory already exists — skipping copy.');
+ return true;
+ }
+
+ if (!is_dir($src)) {
+ self::log('Source media directory not found: ' . $src, 'warning');
+ return true; // Non-critical
+ }
+
+ return Folder::copy($src, $dst);
+ }
+
+ /**
+ * Rename internal references in the copied template.
+ */
+ private static function renameInternals(): void
+ {
+ $base = JPATH_ROOT . '/templates/' . self::NEW_NAME;
+ $mediaBase = JPATH_ROOT . '/media/templates/site/' . self::NEW_NAME;
+
+ // templateDetails.xml — name, element, update servers, paths
+ $manifest = $base . '/templateDetails.xml';
+ if (is_file($manifest)) {
+ $content = file_get_contents($manifest);
+ $content = str_replace(self::OLD_DISPLAY, self::NEW_DISPLAY, $content);
+ $content = str_replace(self::OLD_NAME, self::NEW_NAME, $content);
+ // Update the update server URLs to point to MokoOnyx repo
+ $content = str_replace('MokoCassiopeia', 'MokoOnyx', $content);
+ file_put_contents($manifest, $content);
+ self::log('Updated templateDetails.xml for MokoOnyx.');
+ }
+
+ // joomla.asset.json
+ $assetFile = $base . '/joomla.asset.json';
+ if (is_file($assetFile)) {
+ $content = file_get_contents($assetFile);
+ $content = str_replace(self::OLD_NAME, self::NEW_NAME, $content);
+ $content = str_replace(self::OLD_DISPLAY, self::NEW_DISPLAY, $content);
+ file_put_contents($assetFile, $content);
+ }
+
+ // Language files
+ $langDirs = [
+ $base . '/language/en-GB',
+ $base . '/language/en-US',
+ ];
+ foreach ($langDirs as $langDir) {
+ if (!is_dir($langDir)) continue;
+
+ foreach (glob($langDir . '/*mokocassiopeia*') as $file) {
+ $newFile = str_replace(self::OLD_NAME, self::NEW_NAME, $file);
+ if (is_file($file)) {
+ $content = file_get_contents($file);
+ $content = str_replace('MOKOCASSIOPEIA', 'MOKOONYX', $content);
+ $content = str_replace(self::OLD_DISPLAY, self::NEW_DISPLAY, $content);
+ $content = str_replace(self::OLD_NAME, self::NEW_NAME, $content);
+ file_put_contents($newFile, $content);
+ if ($newFile !== $file) {
+ File::delete($file);
+ }
+ }
+ }
+ }
+
+ // script.php — class name
+ $scriptFile = $base . '/script.php';
+ if (is_file($scriptFile)) {
+ $content = file_get_contents($scriptFile);
+ $content = str_replace('Tpl_MokocassiopeiaInstallerScript', 'Tpl_MokoonyxInstallerScript', $content);
+ $content = str_replace(self::OLD_DISPLAY, self::NEW_DISPLAY, $content);
+ $content = str_replace(self::OLD_NAME, self::NEW_NAME, $content);
+ // Remove the bridge migration call from the new template's script
+ $content = preg_replace(
+ '/\/\/ Bridge migration.*?MokoBridgeMigration::run\(\);/s',
+ '// Migration complete — this is MokoOnyx',
+ $content
+ );
+ file_put_contents($scriptFile, $content);
+ }
+
+ // Remove bridge helper from the new template (not needed)
+ $bridgeFile = $base . '/helper/bridge.php';
+ if (is_file($bridgeFile)) {
+ File::delete($bridgeFile);
+ }
+
+ self::log('Renamed internal references in MokoOnyx.');
+ }
+
+ /**
+ * Migrate database records: template_styles, menu assignments.
+ */
+ private static function migrateDatabase(\Joomla\Database\DatabaseInterface $db): void
+ {
+ // Get existing MokoCassiopeia styles
+ $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 in database.', 'warning');
+ return;
+ }
+
+ foreach ($oldStyles as $oldStyle) {
+ // Check if MokoOnyx style already exists
+ $query = $db->getQuery(true)
+ ->select('COUNT(*)')
+ ->from('#__template_styles')
+ ->where($db->quoteName('template') . ' = ' . $db->quote(self::NEW_NAME))
+ ->where($db->quoteName('title') . ' = ' . $db->quote(
+ str_replace(self::OLD_DISPLAY, self::NEW_DISPLAY, $oldStyle->title)
+ ));
+ $exists = (int) $db->setQuery($query)->loadResult();
+
+ if ($exists > 0) {
+ continue;
+ }
+
+ // Create new style with same params
+ $newStyle = clone $oldStyle;
+ unset($newStyle->id);
+ $newStyle->template = self::NEW_NAME;
+ $newStyle->title = str_replace(self::OLD_DISPLAY, self::NEW_DISPLAY, $oldStyle->title);
+
+ // Update params: replace any mokocassiopeia paths
+ $params = $newStyle->params;
+ if (is_string($params)) {
+ $params = str_replace(self::OLD_NAME, self::NEW_NAME, $params);
+ $newStyle->params = $params;
+ }
+
+ $db->insertObject('#__template_styles', $newStyle, 'id');
+ $newId = $newStyle->id;
+
+ // Copy menu assignments
+ $query = $db->getQuery(true)
+ ->select('menuid')
+ ->from('#__template_styles_menus') // Joomla 5 uses this table
+ ->where('template_style_id = ' . (int) $oldStyle->id);
+
+ try {
+ $menuIds = $db->setQuery($query)->loadColumn();
+ foreach ($menuIds as $menuId) {
+ $obj = (object) [
+ 'template_style_id' => $newId,
+ 'menuid' => $menuId,
+ ];
+ $db->insertObject('#__template_styles_menus', $obj);
+ }
+ } catch (\Exception $e) {
+ // Table may not exist in all Joomla versions
+ }
+
+ // If this was the default style, make MokoOnyx the default
+ if ($oldStyle->home == 1) {
+ // Set MokoOnyx as default
+ $query = $db->getQuery(true)
+ ->update('#__template_styles')
+ ->set($db->quoteName('home') . ' = 1')
+ ->where('id = ' . (int) $newId);
+ $db->setQuery($query)->execute();
+
+ // Unset MokoCassiopeia as default
+ $query = $db->getQuery(true)
+ ->update('#__template_styles')
+ ->set($db->quoteName('home') . ' = 0')
+ ->where('id = ' . (int) $oldStyle->id);
+ $db->setQuery($query)->execute();
+
+ self::log('Set MokoOnyx as default site template.');
+ }
+ }
+
+ // Register the new template in the extensions table
+ self::registerExtension($db);
+
+ self::log('Database migration completed. ' . count($oldStyles) . ' style(s) migrated.');
+ }
+
+ /**
+ * Register MokoOnyx in the extensions table so Joomla recognizes it.
+ */
+ private static function registerExtension(\Joomla\Database\DatabaseInterface $db): void
+ {
+ // Check if already registered
+ $query = $db->getQuery(true)
+ ->select('extension_id')
+ ->from('#__extensions')
+ ->where($db->quoteName('element') . ' = ' . $db->quote(self::NEW_NAME))
+ ->where($db->quoteName('type') . ' = ' . $db->quote('template'));
+ $existing = $db->setQuery($query)->loadResult();
+
+ if ($existing) {
+ self::log('MokoOnyx already registered in extensions table.');
+ return;
+ }
+
+ // Get the old extension record as a base
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from('#__extensions')
+ ->where($db->quoteName('element') . ' = ' . $db->quote(self::OLD_NAME))
+ ->where($db->quoteName('type') . ' = ' . $db->quote('template'));
+ $oldExt = $db->setQuery($query)->loadObject();
+
+ if (!$oldExt) {
+ self::log('MokoCassiopeia extension record not found.', 'warning');
+ return;
+ }
+
+ $newExt = clone $oldExt;
+ unset($newExt->extension_id);
+ $newExt->element = self::NEW_NAME;
+ $newExt->name = self::NEW_NAME;
+
+ // Update manifest_cache with new name
+ $cache = json_decode($newExt->manifest_cache, true);
+ if (is_array($cache)) {
+ $cache['name'] = self::NEW_DISPLAY;
+ $newExt->manifest_cache = json_encode($cache);
+ }
+
+ $db->insertObject('#__extensions', $newExt, 'extension_id');
+ self::log('Registered MokoOnyx in extensions table (ID: ' . $newExt->extension_id . ').');
+ }
+
+ /**
+ * Log a message.
+ */
+ 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');
+ }
+}
diff --git a/updates.xml b/updates.xml
index 1439dec..35e22d8 100644
--- a/updates.xml
+++ b/updates.xml
@@ -19,7 +19,7 @@
https://git.mokoconsulting.tech/MokoConsulting/MokoCassiopeia/releases/download/development/mokocassiopeia-03.09.16-dev.zip
- sha256:2986f08b59617a18d489e0d9e6e49d329ceb8297ae4755b6697f3326c2a41fc4
+ 2986f08b59617a18d489e0d9e6e49d329ceb8297ae4755b6697f3326c2a41fc4
development
Moko Consulting
https://mokoconsulting.tech