* * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later */ /** * Template install/update/uninstall script. * Joomla calls the methods in this class automatically during template * install, update, and uninstall via the element in * templateDetails.xml. * * On first install, detects MokoCassiopeia and migrates template styles, * parameters, menu assignments, and user files automatically. */ defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\Installer\InstallerAdapter; use Joomla\CMS\Installer\InstallerScriptInterface; use Joomla\CMS\Log\Log; class Tpl_MokoonyxInstallerScript implements InstallerScriptInterface { private const MIN_PHP = '8.1.0'; private const MIN_JOOMLA = '4.4.0'; private const OLD_NAME = 'mokocassiopeia'; private const NEW_NAME = 'mokoonyx'; private const OLD_DISPLAY = 'MokoCassiopeia'; private const NEW_DISPLAY = 'MokoOnyx'; public function preflight(string $type, InstallerAdapter $parent): bool { if (version_compare(PHP_VERSION, self::MIN_PHP, '<')) { Factory::getApplication()->enqueueMessage( sprintf('MokoOnyx requires PHP %s or later. You are running PHP %s.', self::MIN_PHP, PHP_VERSION), 'error' ); return false; } if (version_compare(JVERSION, self::MIN_JOOMLA, '<')) { Factory::getApplication()->enqueueMessage( sprintf('MokoOnyx requires Joomla %s or later. You are running Joomla %s.', self::MIN_JOOMLA, JVERSION), 'error' ); return false; } return true; } public function install(InstallerAdapter $parent): bool { $this->logMessage('MokoOnyx template installed.'); return true; } public function update(InstallerAdapter $parent): bool { $this->logMessage('MokoOnyx template updated.'); $this->migrateUpdateServer(); $synced = $this->syncCustomVariables($parent); if ($synced > 0) { Factory::getApplication()->enqueueMessage( sprintf( 'MokoOnyx: %d new CSS variable(s) were added to your custom palette files. ' . 'Review them in your light.custom.css and/or dark.custom.css to customise the new defaults.', $synced ), 'notice' ); } return true; } public function uninstall(InstallerAdapter $parent): bool { $this->logMessage('MokoOnyx template uninstalled.'); return true; } public function postflight(string $type, InstallerAdapter $parent): bool { if ($type === 'install' || $type === 'update') { $this->migrateFromCassiopeia(); $this->replaceCassiopeiaReferences(); $this->clearFaviconStamp(); $this->cleanMediaFolder(); $this->removeDeletedFiles(); $this->lockExtension(); } return true; } /** * Replace MokoCassiopeia references in article content and module content. */ private function replaceCassiopeiaReferences(): void { $db = Factory::getDbo(); // Replace in article content (introtext + fulltext) foreach (['introtext', 'fulltext'] as $col) { try { $query = $db->getQuery(true) ->update('#__content') ->set( $db->quoteName($col) . ' = REPLACE(REPLACE(' . $db->quoteName($col) . ', ' . $db->quote(self::OLD_DISPLAY) . ', ' . $db->quote(self::NEW_DISPLAY) . '), ' . $db->quote(self::OLD_NAME) . ', ' . $db->quote(self::NEW_NAME) . ')' ) ->where( '(' . $db->quoteName($col) . ' LIKE ' . $db->quote('%' . self::OLD_DISPLAY . '%') . ' OR ' . $db->quoteName($col) . ' LIKE ' . $db->quote('%' . self::OLD_NAME . '%') . ')' ); $db->setQuery($query)->execute(); $n = $db->getAffectedRows(); if ($n > 0) { $this->logMessage("Replaced MokoCassiopeia in {$n} content row(s) ({$col})."); } } catch (\Throwable $e) { $this->logMessage('Content replacement failed (' . $col . '): ' . $e->getMessage(), 'warning'); } } // Replace in module content (custom HTML modules etc.) try { $query = $db->getQuery(true) ->update('#__modules') ->set( $db->quoteName('content') . ' = REPLACE(REPLACE(' . $db->quoteName('content') . ', ' . $db->quote(self::OLD_DISPLAY) . ', ' . $db->quote(self::NEW_DISPLAY) . '), ' . $db->quote(self::OLD_NAME) . ', ' . $db->quote(self::NEW_NAME) . ')' ) ->where( '(' . $db->quoteName('content') . ' LIKE ' . $db->quote('%' . self::OLD_DISPLAY . '%') . ' OR ' . $db->quoteName('content') . ' LIKE ' . $db->quote('%' . self::OLD_NAME . '%') . ')' ); $db->setQuery($query)->execute(); $n = $db->getAffectedRows(); if ($n > 0) { $this->logMessage("Replaced MokoCassiopeia in {$n} module(s)."); } } catch (\Throwable $e) { $this->logMessage('Module replacement failed: ' . $e->getMessage(), 'warning'); } } /** * Delete the favicon stamp file so favicons and site.webmanifest * are regenerated on the next page load after install/update. * Also removes the old /images/favicons/ location. */ private function clearFaviconStamp(): void { // Clear new location stamp $stampFile = JPATH_ROOT . '/media/templates/site/' . self::NEW_NAME . '/images/favicons/.favicon_generated'; if (is_file($stampFile)) { @unlink($stampFile); $this->logMessage('Cleared favicon stamp — will regenerate on next page load.'); } // Remove old /images/favicons/ directory from previous versions $oldDir = JPATH_ROOT . '/images/favicons'; if (is_dir($oldDir)) { $files = glob($oldDir . '/*'); if ($files) { foreach ($files as $file) { @unlink($file); } } @unlink($oldDir . '/.favicon_generated'); @rmdir($oldDir); $this->logMessage('Removed old favicon directory: images/favicons/'); } // Remove any favicon files left in the site root $rootFavicons = ['favicon.ico', 'favicon.png', 'apple-touch-icon.png', 'site.webmanifest']; foreach ($rootFavicons as $file) { $path = JPATH_ROOT . '/' . $file; if (is_file($path)) { @unlink($path); $this->logMessage('Removed root favicon: ' . $file); } } } /** * Detect MokoCassiopeia and create matching MokoOnyx styles with the same params. * Creates a MokoOnyx style copy for each MokoCassiopeia style. */ private function migrateFromCassiopeia(): void { $db = Factory::getDbo(); // Get all 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)) { $this->logMessage('No MokoCassiopeia styles found — fresh install.'); return; } $this->logMessage('MokoCassiopeia detected — creating ' . count($oldStyles) . ' matching MokoOnyx style(s).'); // Get the installer-created default MokoOnyx style (to apply params to it) $query = $db->getQuery(true) ->select('id') ->from('#__template_styles') ->where($db->quoteName('template') . ' = ' . $db->quote(self::NEW_NAME)) ->where($db->quoteName('client_id') . ' = 0') ->order($db->quoteName('id') . ' ASC'); $defaultOnyxId = (int) $db->setQuery($query, 0, 1)->loadResult(); $firstStyle = true; foreach ($oldStyles as $oldStyle) { $newTitle = str_replace(self::OLD_DISPLAY, self::NEW_DISPLAY, $oldStyle->title); $newTitle = str_replace(self::OLD_NAME, self::NEW_NAME, $newTitle); $params = is_string($oldStyle->params) ? str_replace(self::OLD_NAME, self::NEW_NAME, $oldStyle->params) : $oldStyle->params; if ($firstStyle && $defaultOnyxId) { // Update the installer-created default style with the first MokoCassiopeia style's params $update = $db->getQuery(true) ->update('#__template_styles') ->set($db->quoteName('params') . ' = ' . $db->quote($params)) ->set($db->quoteName('title') . ' = ' . $db->quote($newTitle)) ->where('id = ' . $defaultOnyxId); $db->setQuery($update)->execute(); // Set as default if MokoCassiopeia was default if ($oldStyle->home == 1) { $db->setQuery( $db->getQuery(true) ->update('#__template_styles') ->set($db->quoteName('home') . ' = 1') ->where('id = ' . $defaultOnyxId) )->execute(); $db->setQuery( $db->getQuery(true) ->update('#__template_styles') ->set($db->quoteName('home') . ' = 0') ->where('id = ' . (int) $oldStyle->id) )->execute(); $this->logMessage('Set MokoOnyx as default site template.'); } $this->logMessage("Updated default MokoOnyx style with params: {$newTitle}"); $firstStyle = false; continue; } // For additional styles: create new MokoOnyx style copies $newStyle = clone $oldStyle; unset($newStyle->id); $newStyle->template = self::NEW_NAME; $newStyle->title = $newTitle; $newStyle->home = 0; $newStyle->params = $params; try { $db->insertObject('#__template_styles', $newStyle, 'id'); $this->logMessage("Created MokoOnyx style: {$newTitle}"); } catch (\Throwable $e) { $this->logMessage("Failed to create style {$newTitle}: " . $e->getMessage(), 'warning'); } } // 2. Copy user files (custom themes, user.css, user.js) $this->copyUserFiles(); // 3. Notify admin Factory::getApplication()->enqueueMessage( 'MokoOnyx has been installed as a replacement for MokoCassiopeia.
' . 'Your template settings and custom files have been migrated automatically. ' . 'MokoOnyx is now your active site template. ' . 'You can safely uninstall MokoCassiopeia from Extensions → Manage.', 'success' ); } /** * Copy user-specific files from MokoCassiopeia media to MokoOnyx media. */ private function copyUserFiles(): void { $oldMedia = JPATH_ROOT . '/media/templates/site/' . self::OLD_NAME; $newMedia = JPATH_ROOT . '/media/templates/site/' . self::NEW_NAME; if (!is_dir($oldMedia) || !is_dir($newMedia)) { return; } $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', ]; $copied = 0; foreach ($userFiles as $relPath) { $src = $oldMedia . '/' . $relPath; $dst = $newMedia . '/' . $relPath; if (is_file($src) && !is_file($dst)) { $dstDir = dirname($dst); if (!is_dir($dstDir)) { mkdir($dstDir, 0755, true); } copy($src, $dst); $copied++; } } if ($copied > 0) { $this->logMessage("Copied {$copied} user file(s) from MokoCassiopeia."); } } private function syncCustomVariables(InstallerAdapter $parent): int { $templateDir = $parent->getParent()->getPath('source'); $syncScript = $templateDir . '/sync_custom_vars.php'; if (!is_file($syncScript)) { $this->logMessage('CSS variable sync script not found at: ' . $syncScript, 'warning'); return 0; } require_once $syncScript; if (!class_exists('MokoCssVarSync')) { $this->logMessage('MokoCssVarSync class not found after loading script.', 'warning'); return 0; } try { $results = MokoCssVarSync::run(JPATH_ROOT); $totalAdded = 0; foreach ($results as $filePath => $result) { $totalAdded += count($result['added']); if (!empty($result['added'])) { $this->logMessage(sprintf('CSS sync: added %d variable(s) to %s', count($result['added']), basename($filePath))); } } return $totalAdded; } catch (\Throwable $e) { $this->logMessage('CSS variable sync failed: ' . $e->getMessage(), 'error'); return 0; } } /** * Lock the extension to prevent uninstallation via Extension Manager. */ private function lockExtension(): void { $db = Factory::getDbo(); try { $query = $db->getQuery(true) ->update('#__extensions') ->set($db->quoteName('locked') . ' = 1') ->where($db->quoteName('element') . ' = ' . $db->quote(self::NEW_NAME)) ->where($db->quoteName('type') . ' = ' . $db->quote('template')); $db->setQuery($query)->execute(); if ($db->getAffectedRows() > 0) { $this->logMessage('MokoOnyx extension locked.'); } } catch (\Throwable $e) { $this->logMessage('Failed to lock extension: ' . $e->getMessage(), 'warning'); } } /** * Clean the template media folder on install/update. * * - Removes stale .min files (regenerated automatically by MokoMinifyHelper) * - Removes deprecated/renamed files from previous versions * - Removes unminified vendor files (vendors ship .min only) */ private function cleanMediaFolder(): void { $mediaRoot = JPATH_ROOT . '/media/templates/site/' . self::NEW_NAME; if (!is_dir($mediaRoot)) { return; } $removed = 0; // 1. Delete all .min.css and .min.js in project dirs (MokoMinifyHelper rebuilds them) // Skip vendor/ — those are pre-minified originals $projectDirs = ['css', 'js']; foreach ($projectDirs as $dir) { $path = $mediaRoot . '/' . $dir; if (!is_dir($path)) continue; $this->deleteMinFilesRecursive($path, $removed); } // 2. Remove unminified vendor files (vendors ship .min only) $vendorUnminified = [ 'vendor/fa7free/css/all.css', 'vendor/fa7free/css/brands.css', 'vendor/fa7free/css/fontawesome.css', 'vendor/fa7free/css/regular.css', 'vendor/fa7free/css/solid.css', ]; foreach ($vendorUnminified as $relPath) { $file = $mediaRoot . '/' . $relPath; if (is_file($file)) { @unlink($file); $removed++; } } // 3. Remove deprecated files from previous versions $deprecated = [ 'css/custom.css', // Renamed to css/user.css 'js/custom.js', // Renamed to js/user.js 'css/template-rtl.css', // No longer used ]; foreach ($deprecated as $relPath) { $file = $mediaRoot . '/' . $relPath; if (is_file($file)) { @unlink($file); $removed++; } } if ($removed > 0) { $this->logMessage("Cleaned media folder: removed {$removed} stale/deprecated file(s)."); } } /** * Recursively delete *.min.css and *.min.js in a directory. */ private function deleteMinFilesRecursive(string $dir, int &$count): void { $entries = scandir($dir); if (!$entries) return; foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') continue; $full = $dir . '/' . $entry; if (is_dir($full)) { $this->deleteMinFilesRecursive($full, $count); } elseif (preg_match('/\.min\.(css|js)$/', $entry)) { @unlink($full); $count++; } } } // ==================================================================== // LICENSE & UPDATE SERVER MIGRATION // ==================================================================== /** * New update server URL (MokoGitea license system). * * TODO: Replace with the actual licensed update server endpoint once * the MokoGitea license system is configured. The URL should * accept a `dlid` query parameter for download-key auth. * * Example: https://updates.mokoconsulting.tech/joomla/mokoonyx/updates.xml */ private const NEW_UPDATE_URL = ''; // TODO: set final URL /** * Old update server URLs that should be removed during migration. */ private const OLD_UPDATE_URLS = [ 'https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/raw/branch/main/updates.xml', ]; /** * Migrate the update server from the old raw-branch URL to the new * MokoGitea license system. * * 1. Find existing update site entries for this template * 2. Remove old entries pointing to the raw-branch URL * 3. (When NEW_UPDATE_URL is set) create the new update site entry * 4. Warn the admin if no download key is configured * * Safe to run multiple times — skips if already migrated. */ private function migrateUpdateServer(): void { if (empty(self::NEW_UPDATE_URL)) { // Migration not yet active — URL not configured return; } $db = Factory::getDbo(); // Find the extension ID for this template $extId = (int) $db->setQuery( $db->getQuery(true) ->select('extension_id') ->from('#__extensions') ->where($db->quoteName('element') . ' = ' . $db->quote(self::NEW_NAME)) ->where($db->quoteName('type') . ' = ' . $db->quote('template')) )->loadResult(); if (!$extId) { return; } // Check if already migrated (new URL exists) $alreadyMigrated = (int) $db->setQuery( $db->getQuery(true) ->select('COUNT(*)') ->from('#__update_sites AS us') ->join('INNER', '#__update_sites_extensions AS use ON us.update_site_id = use.update_site_id') ->where('use.extension_id = ' . $extId) ->where($db->quoteName('us.location') . ' = ' . $db->quote(self::NEW_UPDATE_URL)) )->loadResult(); if ($alreadyMigrated) { $this->checkDownloadKey($extId); return; } // Remove old update site entries $oldSiteIds = $db->setQuery( $db->getQuery(true) ->select('us.update_site_id') ->from('#__update_sites AS us') ->join('INNER', '#__update_sites_extensions AS use ON us.update_site_id = use.update_site_id') ->where('use.extension_id = ' . $extId) ->whereIn($db->quoteName('us.location'), array_map([$db, 'quote'], self::OLD_UPDATE_URLS), true) )->loadColumn(); if (!empty($oldSiteIds)) { $ids = implode(',', array_map('intval', $oldSiteIds)); $db->setQuery( $db->getQuery(true) ->delete('#__update_sites_extensions') ->whereIn('update_site_id', $oldSiteIds) )->execute(); $db->setQuery( $db->getQuery(true) ->delete('#__update_sites') ->whereIn('update_site_id', $oldSiteIds) )->execute(); $this->logMessage('Removed ' . count($oldSiteIds) . ' old update site(s).'); } // Create new update site entry $newSite = (object) [ 'name' => 'MokoOnyx Updates (Licensed)', 'type' => 'extension', 'location' => self::NEW_UPDATE_URL, 'enabled' => 1, 'last_check_timestamp' => 0, 'extra_query' => '', ]; $db->insertObject('#__update_sites', $newSite, 'update_site_id'); $newSiteId = (int) $newSite->update_site_id; if ($newSiteId) { $link = (object) [ 'update_site_id' => $newSiteId, 'extension_id' => $extId, ]; $db->insertObject('#__update_sites_extensions', $link); $this->logMessage('Created new licensed update site (ID: ' . $newSiteId . ').'); } $this->checkDownloadKey($extId); } /** * Check whether a download key is configured for this extension's * update site and warn the admin if not. */ private function checkDownloadKey(int $extId): void { $db = Factory::getDbo(); $row = $db->setQuery( $db->getQuery(true) ->select(['us.update_site_id', 'us.extra_query']) ->from('#__update_sites AS us') ->join('INNER', '#__update_sites_extensions AS use ON us.update_site_id = use.update_site_id') ->where('use.extension_id = ' . $extId) ->where($db->quoteName('us.location') . ' = ' . $db->quote(self::NEW_UPDATE_URL)) )->loadObject(); if (!$row) { return; } // Joomla stores the download key in extra_query as "dlid=XXXXX" if (empty($row->extra_query) || strpos($row->extra_query, 'dlid=') === false) { $editUrl = 'index.php?option=com_installer&view=updatesites&task=updatesite.edit&update_site_id=' . (int) $row->update_site_id; Factory::getApplication()->enqueueMessage( 'MokoOnyx — Download key required.
' . 'A download key is needed to receive updates. ' . 'Enter your download key here.', 'warning' ); } } /** * Remove files and directories that were shipped in previous versions * but have since been deleted from the package. * * Joomla's installer never deletes files on upgrade — it only * adds/overwrites. This method fills that gap so stale overrides * and deprecated assets don't linger on disk. * * Maintain this list: when you delete a file from the repo, add its * path here (relative to the template root) so existing installs * get cleaned up on the next update. */ private function removeDeletedFiles(): void { $templateRoot = JPATH_ROOT . '/templates/' . self::NEW_NAME; // Paths relative to templates/mokoonyx/ $deletedFiles = [ // JoomGallery template overrides — removed in 02.19.00 'html/com_joomgallery/category/default.php', 'html/com_joomgallery/category/default_cat.php', 'html/com_joomgallery/category/index.html', 'html/com_joomgallery/gallery/default.php', 'html/com_joomgallery/gallery/index.html', 'html/com_joomgallery/image/default.php', 'html/com_joomgallery/image/index.html', ]; // Directories to remove (only if empty after file deletion) $deletedDirs = [ 'html/com_joomgallery/image', 'html/com_joomgallery/gallery', 'html/com_joomgallery/category', 'html/com_joomgallery', ]; $removed = 0; foreach ($deletedFiles as $relPath) { $file = $templateRoot . '/' . $relPath; if (is_file($file)) { @unlink($file); $removed++; } } foreach ($deletedDirs as $relPath) { $dir = $templateRoot . '/' . $relPath; if (is_dir($dir)) { // Only remove if empty $entries = @scandir($dir); if ($entries && count($entries) <= 2) { // . and .. only @rmdir($dir); } } } if ($removed > 0) { $this->logMessage("Removed {$removed} deprecated file(s) from previous versions."); } } private function logMessage(string $message, string $priority = 'info'): void { $priorities = [ 'info' => Log::INFO, 'warning' => Log::WARNING, 'error' => Log::ERROR, ]; Log::addLogger( ['text_file' => 'mokoonyx.log.php'], Log::ALL, ['mokoonyx'] ); Log::add($message, $priorities[$priority] ?? Log::INFO, 'mokoonyx'); } }