| 1 |
<?php |
| 2 |
|
| 3 |
namespace Extendify\PluginNotifications; |
| 4 |
|
| 5 |
defined('ABSPATH') || die('No direct access.'); |
| 6 |
|
| 7 |
use Extendify\Config; |
| 8 |
use Extendify\Shared\Services\SupportedPlugins; |
| 9 |
|
| 10 |
class Admin |
| 11 |
{ |
| 12 |
private static $pluginsCache = null; |
| 13 |
|
| 14 |
public function __construct() |
| 15 |
{ |
| 16 |
\add_action('in_admin_header', [$this, 'captureAndSuppressNotices'], 1); |
| 17 |
\add_action('admin_init', [AdminPage::class, 'handleActions']); |
| 18 |
\add_action('admin_menu', [$this, 'addMenu']); |
| 19 |
\add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']); |
| 20 |
} |
| 21 |
|
| 22 |
public function captureAndSuppressNotices() |
| 23 |
{ |
| 24 |
global $wp_filter; |
| 25 |
|
| 26 |
$allCallbacks = []; |
| 27 |
|
| 28 |
foreach (['admin_notices', 'all_admin_notices'] as $hook) { |
| 29 |
if (!isset($wp_filter[$hook])) { |
| 30 |
continue; |
| 31 |
} |
| 32 |
|
| 33 |
foreach ($wp_filter[$hook]->callbacks as $priority => $callbacks) { |
| 34 |
foreach ($callbacks as $id => $cb) { |
| 35 |
$allCallbacks[] = $cb; |
| 36 |
} |
| 37 |
} |
| 38 |
} |
| 39 |
|
| 40 |
\remove_all_actions('admin_notices'); |
| 41 |
\remove_all_actions('all_admin_notices'); |
| 42 |
|
| 43 |
\add_action('admin_notices', function () use ($allCallbacks) { |
| 44 |
$stored = \get_option('extendify_captured_notifications') ?: []; |
| 45 |
$changed = false; |
| 46 |
|
| 47 |
foreach ($allCallbacks as $cb) { |
| 48 |
$fn = $cb['function']; |
| 49 |
$file = self::getCallbackFile($fn); |
| 50 |
|
| 51 |
$extendifyDir = basename(rtrim(EXTENDIFY_PATH, '/\\')); |
| 52 |
try { |
| 53 |
if ($file && str_contains($file, '/plugins/' . $extendifyDir . '/')) { |
| 54 |
\call_user_func($fn); |
| 55 |
continue; |
| 56 |
} |
| 57 |
|
| 58 |
\ob_start(); |
| 59 |
\call_user_func($fn); |
| 60 |
$html = \ob_get_clean(); |
| 61 |
} catch (\Throwable $e) { |
| 62 |
\ob_end_clean(); |
| 63 |
\add_action('admin_notices', $fn, 11); |
| 64 |
continue; |
| 65 |
} |
| 66 |
|
| 67 |
if (empty(trim($html))) { |
| 68 |
continue; |
| 69 |
} |
| 70 |
|
| 71 |
if (empty(trim(strip_tags($html)))) { |
| 72 |
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| 73 |
echo $html; |
| 74 |
continue; |
| 75 |
} |
| 76 |
|
| 77 |
$slug = self::extractPluginSlug($file); |
| 78 |
if (!$slug || !in_array($slug, SupportedPlugins::SLUGS, true)) { |
| 79 |
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| 80 |
echo $html; |
| 81 |
continue; |
| 82 |
} |
| 83 |
|
| 84 |
$hash = md5(trim($html)); |
| 85 |
$sourceName = self::resolvePluginNameFromSlug($slug); |
| 86 |
|
| 87 |
if (isset($stored[$hash])) { |
| 88 |
if ($stored[$hash]['source_name'] !== $sourceName) { |
| 89 |
$stored[$hash]['source_name'] = $sourceName; |
| 90 |
$changed = true; |
| 91 |
} |
| 92 |
continue; |
| 93 |
} |
| 94 |
|
| 95 |
$stored[$hash] = [ |
| 96 |
'id' => $hash, |
| 97 |
'content' => $html, |
| 98 |
'source_name' => $sourceName, |
| 99 |
'notice_type' => self::detectNoticeType($html), |
| 100 |
'first_seen' => current_time('mysql'), |
| 101 |
'dismissed' => false, |
| 102 |
]; |
| 103 |
$changed = true; |
| 104 |
} |
| 105 |
|
| 106 |
if ($changed) { |
| 107 |
if (count($stored) > 200) { |
| 108 |
$stored = self::pruneOldDismissed($stored); |
| 109 |
} |
| 110 |
|
| 111 |
\update_option('extendify_captured_notifications', $stored, false); |
| 112 |
} |
| 113 |
}); |
| 114 |
} |
| 115 |
|
| 116 |
private static function getCallbackFile($fn) |
| 117 |
{ |
| 118 |
try { |
| 119 |
if ($fn instanceof \Closure) { |
| 120 |
return (new \ReflectionFunction($fn))->getFileName(); |
| 121 |
} |
| 122 |
|
| 123 |
if (is_array($fn)) { |
| 124 |
return (new \ReflectionMethod($fn[0], $fn[1]))->getFileName(); |
| 125 |
} |
| 126 |
|
| 127 |
if (is_string($fn) && function_exists($fn)) { |
| 128 |
return (new \ReflectionFunction($fn))->getFileName(); |
| 129 |
} |
| 130 |
} catch (\ReflectionException $e) { |
| 131 |
return null; |
| 132 |
} |
| 133 |
|
| 134 |
return null; |
| 135 |
} |
| 136 |
|
| 137 |
private static function extractPluginSlug($file) |
| 138 |
{ |
| 139 |
if (!$file) { |
| 140 |
return null; |
| 141 |
} |
| 142 |
|
| 143 |
if (preg_match('#[/\\\\]plugins[/\\\\]([^/\\\\]+)[/\\\\]#', $file, $matches)) { |
| 144 |
return $matches[1]; |
| 145 |
} |
| 146 |
|
| 147 |
return null; |
| 148 |
} |
| 149 |
|
| 150 |
private static function resolvePluginNameFromSlug($slug) |
| 151 |
{ |
| 152 |
if (!$slug) { |
| 153 |
return 'WordPress'; |
| 154 |
} |
| 155 |
|
| 156 |
if (self::$pluginsCache === null) { |
| 157 |
if (!function_exists('get_plugins')) { |
| 158 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 159 |
} |
| 160 |
|
| 161 |
self::$pluginsCache = \get_plugins(); |
| 162 |
} |
| 163 |
|
| 164 |
foreach (self::$pluginsCache as $pluginFile => $data) { |
| 165 |
if (strpos($pluginFile, $slug . '/') === 0) { |
| 166 |
return $data['Name']; |
| 167 |
} |
| 168 |
} |
| 169 |
|
| 170 |
return $slug; |
| 171 |
} |
| 172 |
|
| 173 |
private static function detectNoticeType($html) |
| 174 |
{ |
| 175 |
if (str_contains($html, 'notice-error')) { |
| 176 |
return 'error'; |
| 177 |
} |
| 178 |
|
| 179 |
if (str_contains($html, 'notice-warning')) { |
| 180 |
return 'warning'; |
| 181 |
} |
| 182 |
|
| 183 |
if (str_contains($html, 'notice-success')) { |
| 184 |
return 'success'; |
| 185 |
} |
| 186 |
|
| 187 |
return 'info'; |
| 188 |
} |
| 189 |
|
| 190 |
private static function pruneOldDismissed($stored) |
| 191 |
{ |
| 192 |
uasort($stored, function ($a, $b) { |
| 193 |
if ($a['dismissed'] !== $b['dismissed']) { |
| 194 |
return $a['dismissed'] ? -1 : 1; |
| 195 |
} |
| 196 |
|
| 197 |
return strcmp($a['first_seen'], $b['first_seen']); |
| 198 |
}); |
| 199 |
|
| 200 |
return array_slice($stored, -200, null, true); |
| 201 |
} |
| 202 |
|
| 203 |
public function addMenu() |
| 204 |
{ |
| 205 |
$stored = \get_option('extendify_captured_notifications') ?: []; |
| 206 |
$count = count(array_filter($stored, function ($n) { |
| 207 |
return !$n['dismissed']; |
| 208 |
})); |
| 209 |
|
| 210 |
$badge = $count > 0 |
| 211 |
? sprintf(' <span class="awaiting-mod">%d</span>', $count) |
| 212 |
: ''; |
| 213 |
|
| 214 |
$menuLabel = sprintf( |
| 215 |
'%s <span style="white-space:nowrap">%s%s</span>', |
| 216 |
\__('Plugin', 'extendify-local'), |
| 217 |
\__('Notifications', 'extendify-local'), |
| 218 |
$badge |
| 219 |
); |
| 220 |
|
| 221 |
\add_submenu_page( |
| 222 |
'index.php', |
| 223 |
\__('Plugin Notifications', 'extendify-local'), |
| 224 |
$menuLabel, |
| 225 |
Config::$requiredCapability, |
| 226 |
'extendify-notifications', |
| 227 |
[AdminPage::class, 'render'] |
| 228 |
); |
| 229 |
} |
| 230 |
|
| 231 |
public function enqueueAssets($hook) |
| 232 |
{ |
| 233 |
if ($hook !== 'dashboard_page_extendify-notifications') { |
| 234 |
return; |
| 235 |
} |
| 236 |
|
| 237 |
\wp_add_inline_style('wp-admin', ' |
| 238 |
.extendify-notice-detail td { |
| 239 |
padding: 12px 16px; |
| 240 |
background: #f9f9f9; |
| 241 |
} |
| 242 |
.extendify-notice-detail .notice { |
| 243 |
margin: 0; |
| 244 |
box-shadow: none; |
| 245 |
} |
| 246 |
.extendify-notice-badge { |
| 247 |
display: inline-block; |
| 248 |
padding: 2px 8px; |
| 249 |
border-radius: 3px; |
| 250 |
font-size: 12px; |
| 251 |
font-weight: 500; |
| 252 |
} |
| 253 |
.extendify-notice-badge.type-error { background: #fcf0f0; color: #8a1f1f; } |
| 254 |
.extendify-notice-badge.type-warning { background: #fef8ee; color: #8a6d1f; } |
| 255 |
.extendify-notice-badge.type-success { background: #edf8ee; color: #1f6d3a; } |
| 256 |
.extendify-notice-badge.type-info { background: #f0f0fc; color: #1f3a8a; } |
| 257 |
'); |
| 258 |
|
| 259 |
\wp_add_inline_script('common', " |
| 260 |
document.addEventListener('click', function(e) { |
| 261 |
var btn = e.target.closest('.extendify-notice-toggle'); |
| 262 |
if (!btn) return; |
| 263 |
e.preventDefault(); |
| 264 |
var id = btn.dataset.noticeId; |
| 265 |
var row = document.querySelector('tr[data-detail=\"' + id + '\"]'); |
| 266 |
if (row) { |
| 267 |
var isHidden = row.style.display === 'none'; |
| 268 |
row.style.display = isHidden ? '' : 'none'; |
| 269 |
btn.textContent = isHidden ? |
| 270 |
'" . esc_js(__('Hide', 'extendify-local')) . "' : |
| 271 |
'" . esc_js(__('View', 'extendify-local')) . "'; |
| 272 |
} |
| 273 |
}); |
| 274 |
"); |
| 275 |
} |
| 276 |
|
| 277 |
public static function getNotices($filter = 'active') |
| 278 |
{ |
| 279 |
$stored = \get_option('extendify_captured_notifications') ?: []; |
| 280 |
|
| 281 |
if ($filter === 'active') { |
| 282 |
return array_filter($stored, function ($n) { |
| 283 |
return !$n['dismissed']; |
| 284 |
}); |
| 285 |
} |
| 286 |
|
| 287 |
if ($filter === 'dismissed') { |
| 288 |
return array_filter($stored, function ($n) { |
| 289 |
return $n['dismissed']; |
| 290 |
}); |
| 291 |
} |
| 292 |
|
| 293 |
return $stored; |
| 294 |
} |
| 295 |
|
| 296 |
public static function dismissNotice($id) |
| 297 |
{ |
| 298 |
$stored = \get_option('extendify_captured_notifications') ?: []; |
| 299 |
|
| 300 |
if (isset($stored[$id])) { |
| 301 |
$stored[$id]['dismissed'] = true; |
| 302 |
\update_option('extendify_captured_notifications', $stored, false); |
| 303 |
} |
| 304 |
} |
| 305 |
|
| 306 |
public static function dismissAll() |
| 307 |
{ |
| 308 |
$stored = \get_option('extendify_captured_notifications') ?: []; |
| 309 |
|
| 310 |
foreach ($stored as &$notice) { |
| 311 |
$notice['dismissed'] = true; |
| 312 |
} |
| 313 |
unset($notice); |
| 314 |
|
| 315 |
\update_option('extendify_captured_notifications', $stored, false); |
| 316 |
} |
| 317 |
} |
| 318 |
|