PluginProbe
YayMail – WooCommerce Email Customizer / 4.4.6
YayMail – WooCommerce Email Customizer v4.4.6
4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 trunk 1.9.6 2.1.4 2.1.5 3.2.2 3.2.6 3.2.7.1 3.2.8.1 3.2.9 3.3 3.3.1 3.3.4 3.3.5 3.3.6 3.3.7 3.3.8 3.3.9 3.4 3.4.1 All 57 releases
← All changes | vendor-prefixed/src/AdminShell.php +80 -40 4.4.1 → 4.4.6 View file →
@@ -10,15 +10,17 @@
10 10 use YayMailScoped\YayCommerce\AdminShell\Menu\ExternalPluginMenuAdapter;
11 11 use YayMailScoped\YayCommerce\AdminShell\Menu\PluginSubmenu;
12 12 use YayMailScoped\YayCommerce\AdminShell\Menu\MenuSuppressor;
13 13 use YayMailScoped\YayCommerce\AdminShell\Menu\PagesRouter;
14 +use YayMailScoped\YayCommerce\AdminShell\Menu\SubmenuPositioner;
14 15 use YayMailScoped\YayCommerce\AdminShell\Menu\TopLevelMenu;
15 16 use YayMailScoped\YayCommerce\AdminShell\Pages\RecommendedPluginsPage;
16 17 use YayMailScoped\YayCommerce\AdminShell\Registry\AddonBridge;
17 18 use YayMailScoped\YayCommerce\AdminShell\Registry\LegacyBridge;
18 19 use YayMailScoped\YayCommerce\AdminShell\Registry\LicenseRegistry;
20 +use YayMailScoped\YayCommerce\AdminShell\Support\AdminContext;
19 21 use YayMailScoped\YayCommerce\AdminShell\Support\Constants;
20 -\defined('ABSPATH') || exit;
22 +defined('ABSPATH') || exit;
21 23 /**
22 24 * Public facade — entry point for consuming plugins.
23 25 *
24 26 * Version election: when multiple scoped copies coexist, the highest
@@ -27,9 +29,9 @@
27 29 */
28 30 class AdminShell
29 31 {
30 32 /** Package version — used for cross-scope version election. */
31 - const VERSION = '2.6.1';
33 + const VERSION = '2.8.5';
32 34 private static ?self $instance = null;
33 35 private static bool $booted = \false;
34 36 private static array $enabled_slugs = [];
35 37 /** The scoped prefix for THIS copy (derived from namespace). */
@@ -45,9 +47,9 @@
45 47 * Each scoped copy calls boot(). All register their version in a shared
46 48 * global. Actual menu/page registration is deferred to admin_menu where
47 49 * only the highest version runs.
48 50 */
49 - public static function boot() : void
51 + public static function boot(): void
50 52 {
51 53 if (self::$booted) {
52 54 return;
53 55 }
@@ -56,19 +58,39 @@
56 58 $instance = self::get_instance();
57 59 // Derive our scoped prefix from the namespace (e.g. "YayMailScoped\YayCommerce\AdminShell" → "YayMailScoped")
58 60 $ns = __NAMESPACE__;
59 61 // YayCommerce\AdminShell or ScopedPrefix\YayCommerce\AdminShell
60 - $parts = \explode('\\', $ns);
61 - self::$prefix = \count($parts) > 2 ? $parts[0] : 'default';
62 + $parts = explode('\\', $ns);
63 + self::$prefix = count($parts) > 2 ? $parts[0] : 'default';
62 64 // Register this version in the shared global for cross-scope election
63 65 if (!isset($GLOBALS['yaycommerce_admin_shell_versions'])) {
64 66 $GLOBALS['yaycommerce_admin_shell_versions'] = [];
65 67 }
66 68 $GLOBALS['yaycommerce_admin_shell_versions'][self::$prefix] = ['version' => self::VERSION, 'registry' => $instance->registry, 'boot_cb' => [static::class, 'do_shell_registration']];
67 - // Register the version election — only once (first copy to call boot sets it up)
68 - if (1 === \count($GLOBALS['yaycommerce_admin_shell_versions'])) {
69 - \add_action('admin_menu', [static::class, 'elect_version'], 8);
69 + // Register the version election — only once (first copy to call boot sets it up).
70 + // Bound to both admin_menu and network_admin_menu so election runs in the
71 + // Multisite Network Admin too; only the firing hook's election actually runs.
72 + if (1 === count($GLOBALS['yaycommerce_admin_shell_versions'])) {
73 + AdminContext::bind_menu([static::class, 'elect_version'], 8);
74 + // bind_menu() already wired BOTH hooks here, so mark the network election
75 + // as done. This is load-bearing: it stops the safety net below from adding
76 + // a SECOND network binding (elect_version is NOT idempotent — a double
77 + // binding would run do_shell_registration twice). Only one copy ever sees
78 + // count==1, so this path wires network at most once.
79 + $GLOBALS['yaycommerce_network_election_wired'] = \true;
70 80 }
81 + // Network Admin safety net for MIXED-VERSION installs. Older copies (≤2.6.x)
82 + // wire the election to admin_menu ONLY — they predate network support — and the
83 + // first-boot guard above lets whichever copy loads first (often an old one, by
84 + // plugin folder order) own the wiring. So network_admin_menu would never get an
85 + // election binding even when a newer copy is present. This block is NOT gated by
86 + // that guard: any 2.7.1+ copy wires the network election exactly once (dedicated
87 + // flag), independent of load order, so Network Admin works whenever ≥1 updated
88 + // plugin is active. elect_version still elects the highest version as the winner.
89 + if (empty($GLOBALS['yaycommerce_network_election_wired'])) {
90 + $GLOBALS['yaycommerce_network_election_wired'] = \true;
91 + add_action('network_admin_menu', [static::class, 'elect_version'], 8);
92 + }
71 93 // Legacy bridge — reads yaycommerce_licensing_plugins filter.
72 94 // Runs for ALL versions (uses global WP hooks, contributes to any winning registry).
73 95 $legacy_bridge = new LegacyBridge($instance->registry);
74 96 $legacy_bridge->init();
@@ -81,15 +103,15 @@
81 103 $GLOBALS['yaycommerce_ajax_handlers_registered'] = self::VERSION;
82 104 RecommendedPluginsPage::get_instance();
83 105 }
84 106 }
85 - \do_action('yaycommerce_admin_shell_booted', $instance);
107 + do_action('yaycommerce_admin_shell_booted', $instance);
86 108 }
87 109 /**
88 110 * Version election — picks the highest version and runs its shell registration.
89 111 * Called once at admin_menu priority 8 (before TopLevelMenu at 9).
90 112 */
91 - public static function elect_version() : void
113 + public static function elect_version(): void
92 114 {
93 115 $versions = $GLOBALS['yaycommerce_admin_shell_versions'] ?? [];
94 116 if (empty($versions)) {
95 117 return;
@@ -97,9 +119,9 @@
97 119 // Find highest version
98 120 $winner_prefix = '';
99 121 $winner_ver = '0.0.0';
100 122 foreach ($versions as $prefix => $data) {
101 - if (\version_compare($data['version'], $winner_ver, '>')) {
123 + if (version_compare($data['version'], $winner_ver, '>')) {
102 124 $winner_ver = $data['version'];
103 125 $winner_prefix = $prefix;
104 126 }
105 127 }
@@ -104,16 +126,16 @@
104 126 }
105 127 }
106 128 // Call the winner's registration — may be a different scoped class
107 129 $winner = $versions[$winner_prefix];
108 - if (isset($winner['boot_cb']) && \is_callable($winner['boot_cb'])) {
109 - \call_user_func($winner['boot_cb']);
130 + if (isset($winner['boot_cb']) && is_callable($winner['boot_cb'])) {
131 + call_user_func($winner['boot_cb']);
110 132 }
111 133 }
112 134 /**
113 135 * Register menus/pages — only called by the winning version.
114 136 */
115 - public static function do_shell_registration() : void
137 + public static function do_shell_registration(): void
116 138 {
117 139 $instance = self::get_instance();
118 140 // Merge all registries into the winner's registry
119 141 foreach ($GLOBALS['yaycommerce_admin_shell_versions'] ?? [] as $prefix => $data) {
@@ -131,8 +153,12 @@
131 153 $top_menu = new TopLevelMenu();
132 154 $top_menu->init();
133 155 $router = new PagesRouter($instance->registry);
134 156 $router->init();
157 + // Order all submenus by declared position — runs once, only for the
158 + // winning version, so a single authority reorders every plugin's submenu.
159 + $positioner = new SubmenuPositioner();
160 + $positioner->init();
135 161 }
136 162 /**
137 163 * Register a plugin with the admin shell.
138 164 * Auto-detects pro vs lite via instanceof.
@@ -137,16 +163,28 @@
137 163 * Register a plugin with the admin shell.
138 164 * Auto-detects pro vs lite via instanceof.
139 165 * Runs for ALL versions (not version-gated).
140 166 */
141 - public static function register_plugin(PluginMenuAdapter $adapter) : void
167 + public static function register_plugin(PluginMenuAdapter $adapter): void
142 168 {
143 169 self::validate_adapter($adapter);
170 + // Publish this plugin's intended submenu position into a shared,
171 + // cross-scope map (keyed by menu slug). The version-elected winner reads
172 + // this in SubmenuPositioner to order ALL submenus after registration.
173 + // Request-scoped: not pruned (globals don't persist between requests),
174 + // and stale/unknown slugs are harmless — reorder() sorts them last.
175 + $menu_slug = $adapter->get_menu_slug();
176 + if (!empty($menu_slug)) {
177 + if (!isset($GLOBALS[SubmenuPositioner::POSITION_KEY])) {
178 + $GLOBALS[SubmenuPositioner::POSITION_KEY] = [];
179 + }
180 + $GLOBALS[SubmenuPositioner::POSITION_KEY][$menu_slug] = $adapter->get_settings_page_position();
181 + }
144 182 // Submenu registration — per-plugin, all versions
145 183 $submenu = new PluginSubmenu($adapter);
146 184 $submenu->init();
147 185 // Action links + row meta — per-plugin, all versions
148 - if (\is_admin()) {
186 + if (is_admin()) {
149 187 self::register_plugin_links($adapter);
150 188 }
151 189 // License subsystem — per-plugin, all versions
152 190 if ($adapter instanceof LicenseConfigAdapter) {
@@ -167,9 +205,9 @@
167 205 * not appear under the shared YayCommerce menu.
168 206 *
169 207 * @param array{parent_menu: string, menu_title: string, menu_capability: string, menu_slug: string} $config
170 208 */
171 - public static function register_external_plugin_menu(array $config) : void
209 + public static function register_external_plugin_menu(array $config): void
172 210 {
173 211 $adapter = new ExternalPluginMenuAdapter($config);
174 212 $adapter->init();
175 213 }
@@ -175,38 +213,38 @@
175 213 }
176 214 /**
177 215 * Validate adapter values upfront.
178 216 */
179 - private static function validate_adapter(PluginMenuAdapter $adapter) : void
217 + private static function validate_adapter(PluginMenuAdapter $adapter): void
180 218 {
181 219 $menu_title = $adapter->get_menu_title();
182 220 if (empty($menu_title)) {
183 - \trigger_error('[YayCommerce AdminShell] get_menu_title() must return a non-empty string.', \E_USER_WARNING);
221 + trigger_error('[YayCommerce AdminShell] get_menu_title() must return a non-empty string.', \E_USER_WARNING);
184 222 }
185 223 $basename = $adapter->get_plugin_basename();
186 224 if (empty($basename)) {
187 - \trigger_error('[YayCommerce AdminShell] get_plugin_basename() must return a non-empty string.', \E_USER_WARNING);
225 + trigger_error('[YayCommerce AdminShell] get_plugin_basename() must return a non-empty string.', \E_USER_WARNING);
188 226 }
189 227 $callback = $adapter->get_settings_page_callback();
190 - if (null !== $callback && !\is_callable($callback)) {
191 - \trigger_error('[YayCommerce AdminShell] get_settings_page_callback() returned a non-callable value.', \E_USER_WARNING);
228 + if (null !== $callback && !is_callable($callback)) {
229 + trigger_error('[YayCommerce AdminShell] get_settings_page_callback() returned a non-callable value.', \E_USER_WARNING);
192 230 }
193 231 if ($adapter instanceof LicenseConfigAdapter) {
194 232 $slug = $adapter->get_plugin_slug();
195 233 if (empty($slug)) {
196 - \trigger_error('[YayCommerce AdminShell] get_plugin_slug() must return a non-empty string.', \E_USER_WARNING);
234 + trigger_error('[YayCommerce AdminShell] get_plugin_slug() must return a non-empty string.', \E_USER_WARNING);
197 235 }
198 236 $item_id = $adapter->get_item_id();
199 237 if ($item_id <= 0) {
200 - \trigger_error('[YayCommerce AdminShell] get_item_id() must return a positive integer.', \E_USER_WARNING);
238 + trigger_error('[YayCommerce AdminShell] get_item_id() must return a positive integer.', \E_USER_WARNING);
201 239 }
202 240 $store_url = $adapter->get_store_url();
203 241 if (empty($store_url)) {
204 - \trigger_error('[YayCommerce AdminShell] get_store_url() must return a non-empty URL.', \E_USER_WARNING);
242 + trigger_error('[YayCommerce AdminShell] get_store_url() must return a non-empty URL.', \E_USER_WARNING);
205 243 }
206 244 $plugin_file = $adapter->get_plugin_file();
207 245 if (empty($plugin_file)) {
208 - \trigger_error('[YayCommerce AdminShell] get_plugin_file() must return a non-empty path.', \E_USER_WARNING);
246 + trigger_error('[YayCommerce AdminShell] get_plugin_file() must return a non-empty path.', \E_USER_WARNING);
209 247 }
210 248 }
211 249 }
212 250 /**
@@ -212,9 +250,9 @@
212 250 /**
213 251 * Enable the license subsystem for a plugin.
214 252 * Runs for ALL versions (not version-gated).
215 253 */
216 - public static function enable_license(LicenseConfigAdapter $adapter) : void
254 + public static function enable_license(LicenseConfigAdapter $adapter): void
217 255 {
218 256 $slug = $adapter->get_plugin_slug();
219 257 if (isset(self::$enabled_slugs[$slug])) {
220 258 return;
@@ -223,38 +261,38 @@
223 261 $instance = self::get_instance();
224 262 new LicenseHandler($adapter);
225 263 $info = PluginInfoFactory::from_adapter($adapter);
226 264 $instance->registry->register($info);
227 - \do_action('yaycommerce_admin_shell_license_enabled', $adapter);
265 + do_action('yaycommerce_admin_shell_license_enabled', $adapter);
228 266 }
229 267 /**
230 268 * Register plugin action links + row meta.
231 269 */
232 - private static function register_plugin_links(PluginMenuAdapter $adapter) : void
270 + private static function register_plugin_links(PluginMenuAdapter $adapter): void
233 271 {
234 272 $basename = $adapter->get_plugin_basename();
235 - \add_filter('plugin_action_links_' . $basename, function (array $links) use($adapter) {
273 + add_filter('plugin_action_links_' . $basename, function (array $links) use ($adapter) {
236 274 $new = [];
237 275 $menu_slug = $adapter->get_menu_slug();
238 276 if (!empty($menu_slug)) {
239 - $url = \admin_url('admin.php?page=' . $menu_slug);
240 - $new['settings'] = '<a href="' . \esc_url($url) . '">' . \esc_html($adapter->get_settings_label()) . '</a>';
277 + $url = admin_url('admin.php?page=' . $menu_slug);
278 + $new['settings'] = '<a href="' . esc_url($url) . '">' . esc_html($adapter->get_settings_label()) . '</a>';
241 279 }
242 280 $pro_url = $adapter->get_pro_url();
243 281 if (!empty($pro_url)) {
244 - $new['go-pro'] = '<a href="' . \esc_url($pro_url) . '" target="_blank" style="color:#00a32a;font-weight:700;">' . \esc_html__('Go Pro', 'yaycommerce') . '</a>';
282 + $new['go-pro'] = '<a href="' . esc_url($pro_url) . '" target="_blank" style="color:#00a32a;font-weight:700;">' . esc_html__('Go Pro', 'yaycommerce') . '</a>';
245 283 }
246 - return \array_merge($new, $links);
284 + return array_merge($new, $links);
247 285 });
248 - \add_filter('plugin_row_meta', function (array $meta, string $file) use($adapter, $basename) {
286 + add_filter('plugin_row_meta', function (array $meta, string $file) use ($adapter, $basename) {
249 287 if ($file !== $basename) {
250 288 return $meta;
251 289 }
252 290 $docs_url = $adapter->get_docs_url();
253 291 if (!empty($docs_url)) {
254 - $meta[] = '<a href="' . \esc_url($docs_url) . '" target="_blank">' . \esc_html__('Docs', 'yaycommerce') . '</a>';
292 + $meta[] = '<a href="' . esc_url($docs_url) . '" target="_blank">' . esc_html__('Docs', 'yaycommerce') . '</a>';
255 293 }
256 - $meta[] = '<a href="https://yaycommerce.com/support" target="_blank">' . \esc_html__('Support', 'yaycommerce') . '</a>';
294 + $meta[] = '<a href="https://yaycommerce.com/support" target="_blank">' . esc_html__('Support', 'yaycommerce') . '</a>';
257 295 return $meta;
258 296 }, 10, 2);
259 297 }
260 298 /**
@@ -259,13 +297,13 @@
259 297 }
260 298 /**
261 299 * Return the shared registry.
262 300 */
263 - public static function registry() : LicenseRegistry
301 + public static function registry(): LicenseRegistry
264 302 {
265 303 return self::get_instance()->registry;
266 304 }
267 - private static function get_instance() : self
305 + private static function get_instance(): self
268 306 {
269 307 if (null === self::$instance) {
270 308 self::$instance = new self();
271 309 }
@@ -273,9 +311,9 @@
273 311 }
274 312 /**
275 313 * Reset state — for unit tests only.
276 314 */
277 - public static function reset() : void
315 + public static function reset(): void
278 316 {
279 317 self::$instance = null;
280 318 self::$booted = \false;
281 319 self::$enabled_slugs = [];
@@ -281,6 +319,8 @@
281 319 self::$enabled_slugs = [];
282 320 self::$prefix = '';
283 321 unset($GLOBALS['yaycommerce_admin_shell_versions']);
284 322 unset($GLOBALS['yaycommerce_ajax_handlers_registered']);
323 + unset($GLOBALS['yaycommerce_network_election_wired']);
324 + unset($GLOBALS[SubmenuPositioner::POSITION_KEY]);
285 325 }
286 326 }