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 ? `
+
+ ${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)}
+
+
+
+ ${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)} ${blockers.map((item) => `
+ ${filteredBlockers.map((item) => `