Merge pull request #27 from kristianwind/nolimit-menu-and-name
CI / Static checks (push) Successful in 44s
CI / WordPress latest + WooCommerce latest on PHP 8.3 (push) Successful in 1m10s

Rename to Nolimit Discounts and move the page under the shared menu
This commit is contained in:
Kristian Wind
2026-09-17 22:06:44 +02:00
committed by GitHub
4 changed files with 293 additions and 5 deletions
+21 -4
View File
@@ -64,13 +64,30 @@ class WC_EDM_Admin {
} }
/** /**
* Register the submenu page under WooCommerce. * Register the page under the shared Nolimit menu.
*
* wpmgr_ensure_menu() must be called first and is a no-op when another of
* the five plugins already registered the parent — whichever booted first
* owns it, and none of them may assume it is theirs.
*
* The page slug is unchanged, so admin.php?page=… still resolves: moving a
* page between parents does not move its URL, and an existing bookmark or
* a link in an email keeps working.
*/ */
public function register_menu() { public function register_menu() {
if ( function_exists( 'wpmgr_ensure_menu' ) ) {
wpmgr_ensure_menu();
$parent = WPMGR_MENU_SLUG;
} else {
// The shared file is missing — a half-copied upgrade. Fall back to
// the old home rather than losing the page entirely.
$parent = 'woocommerce';
}
add_submenu_page( add_submenu_page(
'woocommerce', $parent,
__( 'Email Discounts', 'wc-email-discount' ), __( 'Discounts', 'wc-email-discount' ),
__( 'Email Discounts', 'wc-email-discount' ), __( 'Discounts', 'wc-email-discount' ),
'manage_woocommerce', 'manage_woocommerce',
self::PAGE_SLUG, self::PAGE_SLUG,
array( $this, 'render_page' ) array( $this, 'render_page' )
+221
View File
@@ -0,0 +1,221 @@
<?php
/**
* The one admin menu the manager plugins share.
*
* ┌──────────────────────────────────────────────────────────────────────────┐
* │ THIS FILE IS BYTE-IDENTICAL IN EVERY MANAGER PLUGIN. │
* │ Do not edit it to add something for one plugin. Extend it through the │
* │ `wpmgr_dashboard_cards` filter, which is what it is for. │
* │ Contract version 1.0.0 — frozen. See "Why it is frozen" below. │
* └──────────────────────────────────────────────────────────────────────────┘
*
* The problem it solves: five plugins from the same family, each adding a
* top-level menu, is five entries in a sidebar that already has fifteen. So
* they share one parent, "Manager", and each adds a submenu under it.
*
* Three things about how it does that are load-bearing, and none is obvious.
*
* **1. Every declaration is guarded.** Five plugins each `require_once` their
* own copy of this file, in one request. `require_once` only deduplicates by
* resolved path, and these are five different paths — so without the
* `function_exists()` guard, activating the second plugin is
* `Fatal error: Cannot redeclare wpmgr_ensure_menu()`. A white screen on a
* shop, caused by nothing except owning two of these plugins. It is the same
* shape as the update-hook collision: a fault that exists only when there is
* more than one, which means no repository's own tests can find it.
*
* **2. Whichever copy loads first wins, and that order is arbitrary** — it is
* plugin activation order, effectively alphabetical by directory. PHP cannot
* un-declare a function, so there is no honest way for a newer copy to replace
* an older one that has already been loaded. The only stable answer is that
* the file never changes. Hence: **frozen**. The parent slug, the capability,
* the position and the filter name are a contract between five repositories
* now, and anything new arrives through the filter.
*
* **3. The dashboard is built from a filter, not owned by a plugin.** If the
* plugin that happened to register the menu is deactivated, the menu is
* re-registered by the next one on the following request and the page still
* renders — because the page's content comes from whoever is installed, not
* from whoever created it. Registering a callback that lives in one plugin
* would mean deactivating that plugin takes the other four's screens with it.
*
* Each plugin calls `wpmgr_ensure_menu()` at the top of its own `admin_menu`
* callback, before its own `add_submenu_page( 'wpmgr', … )`. Not on some other
* hook: `add_submenu_page` against a parent that does not exist yet silently
* fails, and whose `admin_menu` runs first is, again, arbitrary.
*
* The package below is the family, not any one plugin: this file is
* byte-identical in five repositories, and a tag naming whichever of them it
* was first written in reads as a mistake in the other four — which invites
* somebody to "fix" it, in one repository, which is the exact edit the freeze
* exists to prevent.
*
* @package WordPress_Manager_Family
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! defined( 'WPMGR_MENU_VERSION' ) ) {
define( 'WPMGR_MENU_VERSION', '1.0.0' );
}
if ( ! defined( 'WPMGR_MENU_SLUG' ) ) {
define( 'WPMGR_MENU_SLUG', 'wpmgr' );
}
if ( ! function_exists( 'wpmgr_ensure_menu' ) ) {
/**
* Register the shared parent menu, unless somebody already has.
*
* Call this first thing in your own `admin_menu` callback.
*/
function wpmgr_ensure_menu() {
global $admin_page_hooks;
if ( isset( $admin_page_hooks[ WPMGR_MENU_SLUG ] ) ) {
return;
}
/**
* The label on the shared parent menu.
*
* Left untranslated here on purpose: this file is byte-identical in
* five plugins, so it cannot carry any one of their text domains, and
* a string with the wrong domain is a string that is silently never
* translated. Any plugin may translate it in its own domain through
* this filter.
*
* @param string $title The menu title.
*/
$title = apply_filters( 'wpmgr_menu_title', 'Manager' );
add_menu_page(
$title,
$title,
'manage_options',
WPMGR_MENU_SLUG,
'wpmgr_render_dashboard',
'dashicons-admin-generic',
// Just below WooCommerce (55.x) and above Appearance (60). The
// fractional position is not decoration: an integer collides with
// whatever core or another plugin put there, and the loser is
// simply dropped.
58.9
);
}
/**
* The shared landing page.
*
* Nothing here knows which plugins exist. Every card comes from the
* filter, so the page is correct whichever of them registered the menu and
* whichever of them have since been deactivated.
*/
function wpmgr_render_dashboard() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html( 'You do not have permission to view this page.' ) );
}
/**
* Cards on the shared dashboard, one per installed manager plugin.
*
* Each entry:
* title string Plugin name, already translated by its owner.
* status string 'ok' | 'warn' | 'fail' — what the check said.
* headline string One short line: the number that matters.
* lines array label => value, a handful at most.
* url string Where "Open" goes.
* colour string The plugin's own colour, as #rrggbb.
*
* Implementations must not measure anything here. This filter runs on
* an admin page load; a card that walks the disk or makes an HTTP
* request turns somebody else's dashboard into a slow page, and they
* will not know why.
*
* @param array $cards The cards so far.
*/
$cards = apply_filters( 'wpmgr_dashboard_cards', array() );
echo '<div class="wrap wpmgr-dashboard">';
echo '<h1>' . esc_html( apply_filters( 'wpmgr_menu_title', 'Manager' ) ) . '</h1>';
if ( empty( $cards ) ) {
echo '<p>Nothing to report.</p></div>';
return;
}
echo '<style>'
. '.wpmgr-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin-top:20px}'
. '.wpmgr-card{background:#fff;border:1px solid #dcdcde;border-radius:8px;padding:0;overflow:hidden}'
. '.wpmgr-card header{padding:14px 18px;color:#fff;display:flex;justify-content:space-between;align-items:center}'
. '.wpmgr-card header h2{margin:0;font-size:14px;color:inherit}'
. '.wpmgr-card .wpmgr-body{padding:16px 18px}'
. '.wpmgr-headline{font-size:22px;font-weight:600;margin:0 0 10px}'
. '.wpmgr-card dl{display:grid;grid-template-columns:1fr auto;gap:4px 12px;margin:0;font-size:13px}'
. '.wpmgr-card dt{color:#646970;margin:0}.wpmgr-card dd{margin:0;text-align:right}'
. '.wpmgr-state{font-size:11px;text-transform:uppercase;letter-spacing:.04em;'
. 'background:rgba(255,255,255,.22);padding:2px 8px;border-radius:10px}'
. '.wpmgr-card footer{padding:12px 18px;border-top:1px solid #f0f0f1}'
. '</style>';
echo '<div class="wpmgr-cards">';
$states = array(
'ok' => 'OK',
'warn' => 'Attention',
'fail' => 'Action needed',
);
foreach ( $cards as $card ) {
if ( empty( $card['title'] ) ) {
continue;
}
$colour = isset( $card['colour'] ) && preg_match( '/^#[0-9a-f]{6}$/i', $card['colour'] )
? $card['colour']
: '#50575e';
$status = isset( $card['status'] ) && isset( $states[ $card['status'] ] ) ? $card['status'] : 'ok';
echo '<section class="wpmgr-card">';
printf(
'<header style="background:%s"><h2>%s</h2><span class="wpmgr-state">%s</span></header>',
esc_attr( $colour ),
esc_html( $card['title'] ),
esc_html( $states[ $status ] )
);
echo '<div class="wpmgr-body">';
if ( ! empty( $card['headline'] ) ) {
echo '<p class="wpmgr-headline">' . esc_html( $card['headline'] ) . '</p>';
}
if ( ! empty( $card['lines'] ) && is_array( $card['lines'] ) ) {
echo '<dl>';
foreach ( $card['lines'] as $label => $value ) {
echo '<dt>' . esc_html( $label ) . '</dt><dd>' . esc_html( $value ) . '</dd>';
}
echo '</dl>';
}
echo '</div>';
if ( ! empty( $card['url'] ) ) {
printf(
'<footer><a class="button" href="%s">%s</a></footer>',
esc_url( $card['url'] ),
esc_html( isset( $card['action'] ) ? $card['action'] : 'Open' )
);
}
echo '</section>';
}
echo '</div></div>';
}
}
+23
View File
@@ -227,6 +227,29 @@ $request = new WP_REST_Request( 'GET', '/' . WC_EDM_REST_API::REST_NAMESPACE .
$response = rest_get_server()->dispatch( $request ); $response = rest_get_server()->dispatch( $request );
wc_edm_is( 'and they refuse an unauthenticated caller', 401, $response->get_status() ); wc_edm_is( 'and they refuse an unauthenticated caller', 401, $response->get_status() );
/* -------------------------------------------------------------- nolimit -- */
// This plugin claims the shared parent menu's label. Asserted through the
// filter rather than by reading the menu, because the menu is only built in an
// admin request and this runs under WP-CLI.
wc_edm_is(
'the shared menu is labelled Nolimit',
'Nolimit',
apply_filters( 'wpmgr_menu_title', 'Manager' )
);
// includes/shared-menu.php is byte-identical in all five plugins, and which
// copy a site loads is decided by activation order. Editing it here would make
// a site with three of five updated show one label or another depending on who
// booted first — and PHP cannot un-declare a function, so the first copy wins
// for the whole request. The hash is the guard: change the file and this goes
// red rather than the behaviour going strange on somebody's shop.
wc_edm_is(
'and the shared menu file is the family copy, unedited',
'f4862d5c3a2d24dadddaaec90c9221d61f2923964e164fd525a800ddcec63185',
hash_file( 'sha256', WC_EDM_PLUGIN_DIR . 'includes/shared-menu.php' )
);
/* ------------------------------------------------------------ woocommerce -- */ /* ------------------------------------------------------------ woocommerce -- */
$compatible = \Automattic\WooCommerce\Utilities\FeaturesUtil::get_compatible_plugins_for_feature( 'custom_order_tables' ); $compatible = \Automattic\WooCommerce\Utilities\FeaturesUtil::get_compatible_plugins_for_feature( 'custom_order_tables' );
+28 -1
View File
@@ -1,6 +1,6 @@
<?php <?php
/** /**
* Plugin Name: WooCommerce Email Discount Manager * Plugin Name: Nolimit Discounts
* Plugin URI: https://gitea.nolimit.dk/kw/woo-discount-manager * Plugin URI: https://gitea.nolimit.dk/kw/woo-discount-manager
* Update URI: https://gitea.nolimit.dk/kw/woo-discount-manager * Update URI: https://gitea.nolimit.dk/kw/woo-discount-manager
* Description: Apply discounts and show/hide shipping methods based on the customer's email address (full address, domain or partial match). * Description: Apply discounts and show/hide shipping methods based on the customer's email address (full address, domain or partial match).
@@ -123,6 +123,18 @@ final class WC_Email_Discount_Manager {
_doing_it_wrong( __FUNCTION__, 'Unserializing is not allowed.', '1.0.0' ); _doing_it_wrong( __FUNCTION__, 'Unserializing is not allowed.', '1.0.0' );
} }
/**
* The label on the shared parent menu all five plugins register under.
*
* A brand name, so it is returned as a literal rather than run through
* __(): translating it would give the product a different name in Danish.
*
* @return string
*/
public function shared_menu_title() {
return 'Nolimit';
}
/** /**
* Bootstrap once all plugins are loaded. * Bootstrap once all plugins are loaded.
*/ */
@@ -137,6 +149,20 @@ final class WC_Email_Discount_Manager {
$this->updater = new WC_EDM_Updater(); $this->updater = new WC_EDM_Updater();
} }
// Also deliberately before the WooCommerce check, and for the same
// reason as the updater above: the shared parent menu is registered by
// whichever of the five plugins boots first, and its label must not
// depend on whether THIS plugin got past a gate.
//
// Registered here rather than by editing includes/shared-menu.php:
// that file is byte-identical in all five, and which copy a site loads
// is decided by activation order. Changing it there would make a site
// with three of five updated show "Manager" or "Nolimit" depending on
// who booted first, and change its mind when a plugin is deactivated.
// The filter is idempotent, so a plugin still on the old build changes
// nothing.
add_filter( 'wpmgr_menu_title', array( $this, 'shared_menu_title' ) );
if ( ! class_exists( 'WooCommerce' ) ) { if ( ! class_exists( 'WooCommerce' ) ) {
add_action( 'admin_notices', array( $this, 'woocommerce_missing_notice' ) ); add_action( 'admin_notices', array( $this, 'woocommerce_missing_notice' ) );
return; return;
@@ -208,6 +234,7 @@ final class WC_Email_Discount_Manager {
$files = array_merge( $files = array_merge(
$files, $files,
array( array(
'includes/shared-menu.php',
'includes/class-admin.php', 'includes/class-admin.php',
'includes/class-admin-groups.php', 'includes/class-admin-groups.php',
'includes/class-admin-emails.php', 'includes/class-admin-emails.php',