From 6f368f1554512eef718e994039fe8e1a77a64e58 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 2 Apr 2026 08:17:20 -0600 Subject: [PATCH] compatibility support Signed-off-by: Andy Miller --- classes/plugin/AdminController.php | 55 ++++++++ classes/plugin/SafeUpgradeManager.php | 161 ++++++++++++++++++++++++ themes/grav/app/updates/safe-upgrade.js | 115 ++++++++++++++++- 3 files changed, 326 insertions(+), 5 deletions(-) diff --git a/classes/plugin/AdminController.php b/classes/plugin/AdminController.php index 954e9e4a..c1a24459 100644 --- a/classes/plugin/AdminController.php +++ b/classes/plugin/AdminController.php @@ -895,6 +895,61 @@ class AdminController extends AdminBaseController return true; } + /** + * Disable a plugin from the safe upgrade preflight screen. + * + * Used when a plugin has not been marked as compatible with the target Grav version + * and the user chooses to disable it before proceeding with the upgrade. + * + * Route: POST /update.json/task:disablePluginForUpgrade (AJAX call) + * + * @return bool + */ + public function taskDisablePluginForUpgrade() + { + if (!$this->authorizeTask('install grav', ['admin.super'])) { + $this->sendJsonResponse([ + 'status' => 'error', + 'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK') + ]); + + return false; + } + + $post = $this->getPost($_POST ?? []); + $slug = $post['slug'] ?? ''; + + if (!$slug || !preg_match('/^[a-z0-9][-a-z0-9]*$/', $slug)) { + $this->sendJsonResponse([ + 'status' => 'error', + 'message' => 'Invalid plugin slug.', + ]); + + return false; + } + + $configPath = GRAV_ROOT . '/user/config/plugins/' . $slug . '.yaml'; + if (is_file($configPath)) { + $contents = @file_get_contents($configPath); + $data = $contents !== false ? Yaml::parse($contents) : []; + if (!is_array($data)) { + $data = []; + } + } else { + $data = []; + } + + $data['enabled'] = false; + file_put_contents($configPath, Yaml::dump($data)); + + $this->sendJsonResponse([ + 'status' => 'success', + 'slug' => $slug, + ]); + + return true; + } + /** * Poll safe upgrade progress. * diff --git a/classes/plugin/SafeUpgradeManager.php b/classes/plugin/SafeUpgradeManager.php index 93b1f16a..e4ab4a1d 100644 --- a/classes/plugin/SafeUpgradeManager.php +++ b/classes/plugin/SafeUpgradeManager.php @@ -1241,6 +1241,7 @@ class SafeUpgradeManager 'psr_log_conflicts' => [], 'monolog_conflicts' => [], 'plugins_pending' => [], + 'incompatible_packages' => [], 'is_major_minor_upgrade' => $this->isMajorMinorUpgradeLocal($targetVersion), 'blocking' => [], ]; @@ -1261,6 +1262,16 @@ class SafeUpgradeManager $report['warnings'][] = 'Potential Monolog API conflicts detected.'; } + // Check plugin/theme compatibility for major upgrades + if ($report['is_major_minor_upgrade']) { + $report['incompatible_packages'] = $this->detectIncompatiblePackages($targetVersion); + + if (!empty($report['incompatible_packages']['blocking'])) { + $target = $report['incompatible_packages']['target']; + $report['blocking'][] = 'Some enabled plugins/themes have not been marked as compatible with Grav ' . $target . '. Disable them before continuing.'; + } + } + return $report; } @@ -1393,6 +1404,156 @@ class SafeUpgradeManager return null; } + /** + * Read the compatible Grav versions from a package's blueprints.yaml. + * + * If an explicit `compatibility.grav` array is present, return it. + * Otherwise, infer from the `dependencies` array: + * - dependency `grav >= 1.8` → ['1.8'] + * - anything else (or no grav dependency) → ['1.7'] + * + * @param string $dir Package directory + * @return string[] List of compatible major.minor versions (e.g. ['1.7', '1.8']) + */ + protected function readBlueprintCompatibility(string $dir): array + { + $file = $dir . '/blueprints.yaml'; + if (!is_file($file)) { + return []; + } + + try { + $contents = @file_get_contents($file); + if ($contents === false) { + return []; + } + $data = Yaml::parse($contents); + if (!is_array($data)) { + return []; + } + + // Explicit compatibility: property + if (isset($data['compatibility']['grav']) && is_array($data['compatibility']['grav'])) { + return array_map('strval', $data['compatibility']['grav']); + } + + // Inference from dependencies + return $this->inferCompatibleVersions($data['dependencies'] ?? []); + } catch (Throwable $e) { + return []; + } + } + + /** + * Infer compatible Grav versions from a package's dependency list. + * + * Simple rule: if the grav dependency starts at >= 1.8, assume ['1.8']. + * Otherwise default to ['1.7']. + * + * @param array $dependencies The dependencies array from blueprints.yaml + * @return string[] + */ + protected function inferCompatibleVersions(array $dependencies): array + { + foreach ($dependencies as $dep) { + if (!is_array($dep) || ($dep['name'] ?? '') !== 'grav') { + continue; + } + $version = $dep['version'] ?? ''; + + // Extract first numeric version token (handles >=1.8.0, ~1.8, >=1.8.0,<2.0, etc.) + if (!preg_match('/(\d+\.\d+(?:\.\d+)?)/', $version, $m)) { + continue; + } + $numericVersion = $m[1]; + + // If dependency explicitly starts at 1.8+, assume 1.8 only + if (version_compare($numericVersion, '1.8', '>=')) { + return ['1.8']; + } + } + + // Default: assume 1.7 only + return ['1.7']; + } + + /** + * Detect installed plugins (and themes) not compatible with the target Grav version. + * + * Returns separate lists for enabled (blocking) and disabled (warning) packages. + * + * @param string $targetVersion The target Grav version (e.g. '1.8.0') + * @return array{blocking: array, warnings: array, target: string} + */ + protected function detectIncompatiblePackages(string $targetVersion): array + { + $parts = explode('.', $targetVersion); + $targetMajorMinor = ($parts[0] ?? '1') . '.' . ($parts[1] ?? '7'); + + $blocking = []; + $warnings = []; + + // Scan plugins + $pluginDirs = glob(GRAV_ROOT . '/user/plugins/*', GLOB_ONLYDIR) ?: []; + foreach ($pluginDirs as $dir) { + $slug = basename($dir); + $compatibility = $this->readBlueprintCompatibility($dir); + + if (in_array($targetMajorMinor, $compatibility, true)) { + continue; + } + + $version = $this->readBlueprintVersion($dir) ?? 'unknown'; + $enabled = $this->isPluginEnabledLocally($slug); + + $entry = [ + 'type' => 'plugin', + 'version' => $version, + 'compatibility' => $compatibility, + 'enabled' => $enabled, + ]; + + if ($enabled) { + $blocking[$slug] = $entry; + } else { + $warnings[$slug] = $entry; + } + } + + // Scan themes + $themeDirs = glob(GRAV_ROOT . '/user/themes/*', GLOB_ONLYDIR) ?: []; + foreach ($themeDirs as $dir) { + $slug = basename($dir); + $compatibility = $this->readBlueprintCompatibility($dir); + + if (in_array($targetMajorMinor, $compatibility, true)) { + continue; + } + + $version = $this->readBlueprintVersion($dir) ?? 'unknown'; + $active = $this->isThemeEnabledLocally($slug); + + $entry = [ + 'type' => 'theme', + 'version' => $version, + 'compatibility' => $compatibility, + 'enabled' => $active, + ]; + + if ($active) { + $blocking[$slug] = $entry; + } else { + $warnings[$slug] = $entry; + } + } + + return [ + 'blocking' => $blocking, + 'warnings' => $warnings, + 'target' => $targetMajorMinor, + ]; + } + protected function packagesToArray($packages): array { if (!$packages) { diff --git a/themes/grav/app/updates/safe-upgrade.js b/themes/grav/app/updates/safe-upgrade.js index 25bf1149..43c5cdcf 100644 --- a/themes/grav/app/updates/safe-upgrade.js +++ b/themes/grav/app/updates/safe-upgrade.js @@ -20,6 +20,12 @@ const r = (key, value, fallback = '') => { return template.replace('%s', value); }; +const esc = (str) => { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(String(str))); + return div.innerHTML; +}; + const STAGE_TITLES = { queued: () => t('SAFE_UPGRADE_STAGE_QUEUED', 'Waiting for worker'), initializing: () => t('SAFE_UPGRADE_STAGE_INITIALIZING', 'Preparing upgrade'), @@ -77,7 +83,8 @@ export default class SafeUpgrade { return { preflight: `${base}/${task}safeUpgradePreflight/${nonce}`, start: `${base}/${task}safeUpgradeStart/${nonce}`, - status: `${base}/${task}safeUpgradeStatus/${nonce}` + status: `${base}/${task}safeUpgradeStatus/${nonce}`, + disablePlugin: `${base}/${task}disablePluginForUpgrade/${nonce}` }; } @@ -130,6 +137,16 @@ export default class SafeUpgrade { this.updateStartButtonState(); }); + this.modalElement.on('click', '[data-safe-upgrade-disable-plugin]', (event) => { + event.preventDefault(); + const button = $(event.currentTarget); + if (button.prop('disabled')) { + return; + } + const slug = button.data('safe-upgrade-disable-plugin'); + this.disablePluginForUpgrade(slug, button); + }); + this.modalElement.on('closing', (event) => { if (this.modalLocked && event.reason !== 'finish') { event.preventDefault(); @@ -226,6 +243,12 @@ export default class SafeUpgrade { const psrConflicts = (data.preflight && data.preflight.psr_log_conflicts) || {}; const monologConflicts = (data.preflight && data.preflight.monolog_conflicts) || {}; const isMajorUpgrade = !!(data.preflight && data.preflight.is_major_minor_upgrade); + const incompatible =(data.preflight && data.preflight.incompatible_packages) || {}; + const incompatibleBlocking = incompatible.blocking || {}; + const incompatibleWarnings = incompatible.warnings || {}; + const incompatibleTarget = incompatible.target || '?'; + const hasIncompatibleBlocking = Object.keys(incompatibleBlocking).length > 0; + const hasIncompatibleWarnings = Object.keys(incompatibleWarnings).length > 0; const hasPendingUpdates = Object.keys(pending).length > 0; if (data.status === 'error') { @@ -333,6 +356,59 @@ export default class SafeUpgrade { ` : ''; + const incompatibleBlockingList = hasIncompatibleBlocking ? ` +
+
+
+ +
+ ${r('SAFE_UPGRADE_INCOMPATIBLE_TITLE', incompatibleTarget, 'Plugins/themes not compatible with Grav %s')} + ${t('SAFE_UPGRADE_INCOMPATIBLE_DESC', 'These enabled extensions have not been marked as compatible with the target Grav version. Disable them to proceed.')} +
+
+
+
+
    + ${Object.keys(incompatibleBlocking).map((slug) => { + const item = incompatibleBlocking[slug] || {}; + const type = item.type || 'plugin'; + const ver = item.version || t('SAFE_UPGRADE_UNKNOWN_VERSION', 'unknown'); + return `
  • + + ${esc(slug)} (${esc(type)} v${esc(ver)}) + + +
  • `; + }).join('')} +
+
+
+ ` : ''; + + const incompatibleWarningList = hasIncompatibleWarnings ? ` +
+
+
+ +
+ ${r('SAFE_UPGRADE_INCOMPATIBLE_DISABLED_TITLE', incompatibleTarget, 'Disabled extensions not yet compatible with Grav %s')} + ${t('SAFE_UPGRADE_INCOMPATIBLE_DISABLED_DESC', 'These extensions are currently disabled and will not block the upgrade. Re-enable them only after confirming compatibility.')} +
+
+
+
+
    + ${Object.keys(incompatibleWarnings).map((slug) => { + const item = incompatibleWarnings[slug] || {}; + const type = item.type || 'plugin'; + const ver = item.version || t('SAFE_UPGRADE_UNKNOWN_VERSION', 'unknown'); + return `
  • ${esc(slug)} (${esc(type)} v${esc(ver)})
  • `; + }).join('')} +
+
+
+ ` : ''; + const psrList = Object.keys(psrConflicts).length ? `
@@ -363,7 +439,12 @@ export default class SafeUpgrade {
` : ''; - const blockersList = blockers.length ? ` + // Filter out the incompatible blocker text — it has its own dedicated panel + const filteredBlockers = blockers.filter((b) => { + return !(typeof b === 'string' && b.toLowerCase().includes('not been marked as compatible')); + }); + + const blockersList = filteredBlockers.length ? `
@@ -375,7 +456,7 @@ export default class SafeUpgrade {
-
    ${blockers.map((item) => `
  • ${item}
  • `).join('')}
+
    ${filteredBlockers.map((item) => `
  • ${item}
  • `).join('')}
` : ''; @@ -392,6 +473,8 @@ export default class SafeUpgrade { this.steps.preflight.html(`
${summary} + ${incompatibleBlockingList} + ${incompatibleWarningList} ${warningsList} ${pendingList} ${psrList} @@ -403,7 +486,7 @@ export default class SafeUpgrade { this.switchStep('preflight'); const hasBlockingConflicts = (Object.keys(psrConflicts).length && !this.decisions.psr_log) || (Object.keys(monologConflicts).length && !this.decisions.monolog); - const canStart = !blockers.length && !hasBlockingConflicts; + const canStart = !filteredBlockers.length && !hasBlockingConflicts && !hasIncompatibleBlocking; this.buttons.start .removeClass('hidden') @@ -466,11 +549,33 @@ export default class SafeUpgrade { const hasUnresolvedConflicts = unresolved.length > 0; const blockers = this.steps.preflight.find('.safe-upgrade-blockers li'); + const hasIncompatibleBlocking = this.steps.preflight.find('.safe-upgrade-incompatible-blocking li').length > 0; - const disabled = hasUnresolvedConflicts || blockers.length > 0; + const disabled = hasUnresolvedConflicts || blockers.length > 0 || hasIncompatibleBlocking; this.buttons.start.prop('disabled', disabled); } + disablePluginForUpgrade(slug, button) { + button.prop('disabled', true).addClass('is-loading').html( + `` + ); + + request(this.urls.disablePlugin, { method: 'post', body: { slug } }, (response) => { + if (response.status === 'success') { + const row = button.closest('[data-incompatible-slug]'); + row.slideUp(200, () => { + row.remove(); + // Re-run preflight to refresh blocking state + this.fetchPreflight(true); + }); + } else { + button.prop('disabled', false).removeClass('is-loading').html( + t('SAFE_UPGRADE_DISABLE', 'Disable') + ); + } + }); + } + setRecheckLoading(state) { const button = this.modalElement.find('[data-safe-upgrade-action="recheck"]'); if (!button.length) {