From 2c1a7361d723b5db8178a6d5bad96b30ed092982 Mon Sep 17 00:00:00 2001 From: Jonathan Miller <230051081+jmiller-moko@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:53:24 -0500 Subject: [PATCH] fix: merge language overrides instead of replacing existing files Rework the install script to use a sentinel-block pattern (BEGIN/END MokoWaaS Overrides) so existing site overrides are preserved verbatim. On install the block is appended; on update it is stripped and rebuilt; on uninstall only MokoWaaS keys are removed. Also hardcode the plugin display name in the manifest and fix creationDate XML formatting. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/mokowaas.xml | 2 +- src/script.php | 397 +++++++++++++++++++++++++++-------------------- 2 files changed, 233 insertions(+), 166 deletions(-) diff --git a/src/mokowaas.xml b/src/mokowaas.xml index 54401920..7c3e6765 100644 --- a/src/mokowaas.xml +++ b/src/mokowaas.xml @@ -22,7 +22,7 @@ NOTE: Defines installation metadata, files, and configuration for Joomla --> - PLG_SYSTEM_MOKOWAAS + System - MokoWaaS Moko Consulting 2026-03-26 Copyright (C) 2025 Moko Consulting. All rights reserved. diff --git a/src/script.php b/src/script.php index ec72003e..4a97ffde 100644 --- a/src/script.php +++ b/src/script.php @@ -167,11 +167,18 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface return true; } + /** Sentinel comment that marks the start of MokoWaaS overrides inside a Joomla override file. */ + private const BLOCK_START = '; ===== BEGIN MokoWaaS Overrides (do not edit this block) ====='; + + /** Sentinel comment that marks the end of MokoWaaS overrides inside a Joomla override file. */ + private const BLOCK_END = '; ===== END MokoWaaS Overrides ====='; + /** * Install language override files to Joomla's global override directories. * - * This method copies the plugin's language override files to Joomla's global - * language override directories where they will be automatically loaded by Joomla. + * Reads each source override shipped with the plugin, then merges the keys + * into the destination file inside a clearly delimited block. Existing + * overrides outside the block are never touched. * * @return void * @@ -182,92 +189,47 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface $app = Factory::getApplication(); $pluginPath = JPATH_PLUGINS . '/system/mokowaas'; - // Install frontend overrides - foreach ($this->languageTags as $tag) - { - $source = $pluginPath . '/language/overrides/' . $tag . '.override.ini'; - $dest = JPATH_ROOT . '/language/overrides/' . $tag . '.override.ini'; + $overrideSets = [ + // [source folder relative to plugin, Joomla destination base] + ['language/overrides', JPATH_ROOT . '/language/overrides', 'frontend'], + ['administrator/language/overrides', JPATH_ADMINISTRATOR . '/language/overrides', 'administrator'], + ]; - if (file_exists($source)) + foreach ($overrideSets as [$sourceDir, $destDir, $label]) + { + foreach ($this->languageTags as $tag) { - // Ensure destination directory exists - $destDir = dirname($dest); + $source = $pluginPath . '/' . $sourceDir . '/' . $tag . '.override.ini'; + $dest = $destDir . '/' . $tag . '.override.ini'; + + if (!file_exists($source)) + { + continue; + } + if (!is_dir($destDir)) { Folder::create($destDir); } - // Read existing overrides if they exist - $existingOverrides = []; - if (file_exists($dest)) - { - $existingOverrides = $this->parseLanguageFile($dest); - } - - // Read plugin overrides $pluginOverrides = $this->parseLanguageFile($source); - // Merge overrides (plugin overrides take precedence) - $mergedOverrides = array_merge($existingOverrides, $pluginOverrides); + if (empty($pluginOverrides)) + { + continue; + } - // Write merged overrides - if ($this->writeLanguageFile($dest, $mergedOverrides)) + if ($this->mergeOverridesIntoFile($dest, $pluginOverrides)) { $app->enqueueMessage( - sprintf('Installed frontend language overrides for %s', $tag), + sprintf('Installed %s language overrides for %s', $label, $tag), 'message' ); } else { $app->enqueueMessage( - sprintf('Failed to install frontend language overrides for %s', $tag), - 'warning' - ); - } - } - } - - // Install administrator overrides - foreach ($this->languageTags as $tag) - { - $source = $pluginPath . '/administrator/language/overrides/' . $tag . '.override.ini'; - $dest = JPATH_ADMINISTRATOR . '/language/overrides/' . $tag . '.override.ini'; - - if (file_exists($source)) - { - // Ensure destination directory exists - $destDir = dirname($dest); - if (!is_dir($destDir)) - { - Folder::create($destDir); - } - - // Read existing overrides if they exist - $existingOverrides = []; - if (file_exists($dest)) - { - $existingOverrides = $this->parseLanguageFile($dest); - } - - // Read plugin overrides - $pluginOverrides = $this->parseLanguageFile($source); - - // Merge overrides (plugin overrides take precedence) - $mergedOverrides = array_merge($existingOverrides, $pluginOverrides); - - // Write merged overrides - if ($this->writeLanguageFile($dest, $mergedOverrides)) - { - $app->enqueueMessage( - sprintf('Installed administrator language overrides for %s', $tag), - 'message' - ); - } - else - { - $app->enqueueMessage( - sprintf('Failed to install administrator language overrides for %s', $tag), + sprintf('Failed to install %s language overrides for %s', $label, $tag), 'warning' ); } @@ -276,10 +238,11 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface } /** - * Remove language override files from Joomla's global override directories. + * Remove only MokoWaaS overrides from Joomla's global override files. * - * This method removes the plugin's language overrides from Joomla's global - * language override directories on uninstallation. + * Strips the delimited MokoWaaS block and any duplicate keys that appear + * outside the block (safety net for upgrades from older versions that wrote + * keys inline). All other content is preserved verbatim. * * @return void * @@ -290,79 +253,216 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface $app = Factory::getApplication(); $pluginPath = JPATH_PLUGINS . '/system/mokowaas'; - // Remove frontend overrides - foreach ($this->languageTags as $tag) + $overrideSets = [ + ['language/overrides', JPATH_ROOT . '/language/overrides', 'frontend'], + ['administrator/language/overrides', JPATH_ADMINISTRATOR . '/language/overrides', 'administrator'], + ]; + + foreach ($overrideSets as [$sourceDir, $destDir, $label]) { - $source = $pluginPath . '/language/overrides/' . $tag . '.override.ini'; - $dest = JPATH_ROOT . '/language/overrides/' . $tag . '.override.ini'; - - if (file_exists($source) && file_exists($dest)) + foreach ($this->languageTags as $tag) { - // Read plugin overrides - $pluginOverrides = $this->parseLanguageFile($source); + $source = $pluginPath . '/' . $sourceDir . '/' . $tag . '.override.ini'; + $dest = $destDir . '/' . $tag . '.override.ini'; - // Read existing overrides - $existingOverrides = $this->parseLanguageFile($dest); - - // Remove plugin overrides from existing - foreach (array_keys($pluginOverrides) as $key) + if (!file_exists($dest)) { - unset($existingOverrides[$key]); + continue; } - // Write remaining overrides or delete file if empty - if (!empty($existingOverrides)) - { - $this->writeLanguageFile($dest, $existingOverrides); - } - else - { - File::delete($dest); - } + $pluginKeys = array_keys($this->parseLanguageFile($source)); - $app->enqueueMessage( - sprintf('Removed frontend language overrides for %s', $tag), - 'message' - ); + if ($this->removeOverridesFromFile($dest, $pluginKeys)) + { + $app->enqueueMessage( + sprintf('Removed %s language overrides for %s', $label, $tag), + 'message' + ); + } + } + } + } + + /** + * Merge plugin overrides into an existing Joomla override file. + * + * The method: + * 1. Reads the destination file (if it exists) and preserves every line. + * 2. Strips any previous MokoWaaS block so it can be rewritten cleanly. + * 3. Removes duplicate keys that now live inside the MokoWaaS block. + * 4. Appends a new MokoWaaS block at the end of the file. + * + * @param string $dest Absolute path to the Joomla override file + * @param array $overrides Key/value pairs to inject + * + * @return boolean True on success + * + * @since 02.00.00 + */ + private function mergeOverridesIntoFile($dest, array $overrides) + { + $existingLines = []; + + if (file_exists($dest)) + { + $existingLines = file($dest, FILE_IGNORE_NEW_LINES); + } + + // Strip any previous MokoWaaS block + $existingLines = $this->stripMokoWaaSBlock($existingLines); + + // Remove any keys outside the block that we are about to inject + $overrideKeys = array_map('strtoupper', array_keys($overrides)); + $cleanedLines = []; + + foreach ($existingLines as $line) + { + $trimmed = trim($line); + + if ($trimmed !== '' && $trimmed[0] !== ';') + { + if (preg_match('/^([A-Z0-9_]+)\s*=/i', $trimmed, $m)) + { + if (in_array(strtoupper($m[1]), $overrideKeys, true)) + { + // Skip - this key will be in the MokoWaaS block + continue; + } + } + } + + $cleanedLines[] = $line; + } + + // Remove trailing blank lines so the block starts cleanly + while (!empty($cleanedLines) && trim(end($cleanedLines)) === '') + { + array_pop($cleanedLines); + } + + // Build the MokoWaaS block + $block = []; + $block[] = ''; + $block[] = self::BLOCK_START; + $block[] = '; Auto-generated on ' . date('Y-m-d H:i:s') . ' — do not edit manually.'; + + foreach ($overrides as $key => $value) + { + $block[] = strtoupper($key) . '="' . $value . '"'; + } + + $block[] = self::BLOCK_END; + $block[] = ''; + + $content = implode("\n", array_merge($cleanedLines, $block)); + + return File::write($dest, $content); + } + + /** + * Remove MokoWaaS overrides from an existing Joomla override file. + * + * Strips the delimited block and any stray keys that match, then rewrites + * the file. If the file would be empty (or comments-only) it is deleted. + * + * @param string $dest Absolute path to the override file + * @param array $keys The override keys to remove (uppercase) + * + * @return boolean True on success + * + * @since 02.00.00 + */ + private function removeOverridesFromFile($dest, array $keys) + { + if (!file_exists($dest)) + { + return true; + } + + $lines = file($dest, FILE_IGNORE_NEW_LINES); + + // Strip the MokoWaaS block + $lines = $this->stripMokoWaaSBlock($lines); + + // Also strip any stray keys that match (legacy installs) + $upperKeys = array_map('strtoupper', $keys); + $cleaned = []; + + foreach ($lines as $line) + { + $trimmed = trim($line); + + if ($trimmed !== '' && $trimmed[0] !== ';') + { + if (preg_match('/^([A-Z0-9_]+)\s*=/i', $trimmed, $m)) + { + if (in_array(strtoupper($m[1]), $upperKeys, true)) + { + continue; + } + } + } + + $cleaned[] = $line; + } + + // Check whether any real keys remain + $hasKeys = false; + + foreach ($cleaned as $line) + { + $trimmed = trim($line); + + if ($trimmed !== '' && $trimmed[0] !== ';') + { + $hasKeys = true; + break; } } - // Remove administrator overrides - foreach ($this->languageTags as $tag) + if (!$hasKeys) { - $source = $pluginPath . '/administrator/language/overrides/' . $tag . '.override.ini'; - $dest = JPATH_ADMINISTRATOR . '/language/overrides/' . $tag . '.override.ini'; + return File::delete($dest); + } - if (file_exists($source) && file_exists($dest)) + return File::write($dest, implode("\n", $cleaned) . "\n"); + } + + /** + * Remove the MokoWaaS sentinel block from an array of file lines. + * + * @param array $lines Lines of the file (no trailing newlines) + * + * @return array Lines with the block removed + * + * @since 02.00.00 + */ + private function stripMokoWaaSBlock(array $lines) + { + $out = []; + $inBlock = false; + + foreach ($lines as $line) + { + if (trim($line) === self::BLOCK_START) { - // Read plugin overrides - $pluginOverrides = $this->parseLanguageFile($source); + $inBlock = true; + continue; + } - // Read existing overrides - $existingOverrides = $this->parseLanguageFile($dest); + if (trim($line) === self::BLOCK_END) + { + $inBlock = false; + continue; + } - // Remove plugin overrides from existing - foreach (array_keys($pluginOverrides) as $key) - { - unset($existingOverrides[$key]); - } - - // Write remaining overrides or delete file if empty - if (!empty($existingOverrides)) - { - $this->writeLanguageFile($dest, $existingOverrides); - } - else - { - File::delete($dest); - } - - $app->enqueueMessage( - sprintf('Removed administrator language overrides for %s', $tag), - 'message' - ); + if (!$inBlock) + { + $out[] = $line; } } + + return $out; } /** @@ -384,14 +484,14 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface } $content = file_get_contents($filePath); - $lines = explode("\n", $content); + $lines = explode("\n", $content); foreach ($lines as $line) { $line = trim($line); // Skip empty lines and comments - if (empty($line) || $line[0] === ';') + if ($line === '' || $line[0] === ';') { continue; } @@ -399,43 +499,10 @@ class plgSystemMokoWaaSInstallerScript implements InstallerScriptInterface // Parse KEY="VALUE" format if (preg_match('/^([A-Z0-9_]+)="(.+)"$/i', $line, $matches)) { - $key = strtoupper($matches[1]); - $value = $matches[2]; - $strings[$key] = $value; + $strings[strtoupper($matches[1])] = $matches[2]; } } return $strings; } - - /** - * Write language strings to an INI file. - * - * @param string $filePath The path to the language file - * @param array $strings Array of language strings (key => value) - * - * @return boolean True on success, false on failure - * - * @since 02.00.00 - */ - private function writeLanguageFile($filePath, $strings) - { - if (empty($strings)) - { - return false; - } - - $content = "; MokoWaaS Language Overrides\n"; - $content .= "; Generated by MokoWaaS Plugin\n"; - $content .= "; Last updated: " . date('Y-m-d H:i:s') . "\n\n"; - - foreach ($strings as $key => $value) - { - // Escape quotes in value - $value = str_replace('"', '\"', $value); - $content .= strtoupper($key) . '="' . $value . '"' . "\n"; - } - - return File::write($filePath, $content); - } }