mirror of
https://github.com/getgrav/grav-plugin-admin.git
synced 2026-07-24 15:21:06 +02:00
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user