compatibility support

Signed-off-by: Andy Miller <rhuk@mac.com>
This commit is contained in:
Andy Miller
2026-04-02 08:17:20 -06:00
parent eda8d25a6c
commit 6f368f1554
3 changed files with 326 additions and 5 deletions

View File

@@ -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.
*

View File

@@ -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) {

View File

@@ -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 {
</section>
` : '';
const incompatibleBlockingList = hasIncompatibleBlocking ? `
<section class="safe-upgrade-panel safe-upgrade-panel--blocker safe-upgrade-incompatible-blocking">
<header class="safe-upgrade-panel__header">
<div class="safe-upgrade-panel__title-wrap">
<span class="safe-upgrade-panel__icon fa fa-shield-alt" aria-hidden="true"></span>
<div>
<strong class="safe-upgrade-panel__title">${r('SAFE_UPGRADE_INCOMPATIBLE_TITLE', incompatibleTarget, 'Plugins/themes not compatible with Grav %s')}</strong>
<span class="safe-upgrade-panel__subtitle">${t('SAFE_UPGRADE_INCOMPATIBLE_DESC', 'These enabled extensions have not been marked as compatible with the target Grav version. Disable them to proceed.')}</span>
</div>
</div>
</header>
<div class="safe-upgrade-panel__body">
<ul class="safe-upgrade-incompatible-list">
${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 `<li data-incompatible-slug="${esc(slug)}">
<span class="safe-upgrade-incompatible-info">
<code>${esc(slug)}</code> <span class="safe-upgrade-incompatible-meta">(${esc(type)} v${esc(ver)})</span>
</span>
<button class="button button-small secondary" data-safe-upgrade-disable-plugin="${esc(slug)}">${t('SAFE_UPGRADE_DISABLE', 'Disable')}</button>
</li>`;
}).join('')}
</ul>
</div>
</section>
` : '';
const incompatibleWarningList = hasIncompatibleWarnings ? `
<section class="safe-upgrade-panel safe-upgrade-panel--alert safe-upgrade-incompatible-warnings">
<header class="safe-upgrade-panel__header">
<div class="safe-upgrade-panel__title-wrap">
<span class="safe-upgrade-panel__icon fa fa-info-circle" aria-hidden="true"></span>
<div>
<strong class="safe-upgrade-panel__title">${r('SAFE_UPGRADE_INCOMPATIBLE_DISABLED_TITLE', incompatibleTarget, 'Disabled extensions not yet compatible with Grav %s')}</strong>
<span class="safe-upgrade-panel__subtitle">${t('SAFE_UPGRADE_INCOMPATIBLE_DISABLED_DESC', 'These extensions are currently disabled and will not block the upgrade. Re-enable them only after confirming compatibility.')}</span>
</div>
</div>
</header>
<div class="safe-upgrade-panel__body">
<ul>
${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 `<li><code>${esc(slug)}</code> <span class="safe-upgrade-incompatible-meta">(${esc(type)} v${esc(ver)})</span></li>`;
}).join('')}
</ul>
</div>
</section>
` : '';
const psrList = Object.keys(psrConflicts).length ? `
<section class="safe-upgrade-panel safe-upgrade-panel--conflict safe-upgrade-conflict">
<header class="safe-upgrade-panel__header">
@@ -363,7 +439,12 @@ export default class SafeUpgrade {
</section>
` : '';
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 ? `
<section class="safe-upgrade-panel safe-upgrade-panel--blocker safe-upgrade-blockers">
<header class="safe-upgrade-panel__header">
<div class="safe-upgrade-panel__title-wrap">
@@ -375,7 +456,7 @@ export default class SafeUpgrade {
</div>
</header>
<div class="safe-upgrade-panel__body">
<ul>${blockers.map((item) => `<li>${item}</li>`).join('')}</ul>
<ul>${filteredBlockers.map((item) => `<li>${item}</li>`).join('')}</ul>
</div>
</section>
` : '';
@@ -392,6 +473,8 @@ export default class SafeUpgrade {
this.steps.preflight.html(`
<div class="safe-upgrade-preflight">
${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(
`<span class="fa fa-refresh fa-spin" aria-hidden="true"></span>`
);
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) {