= (int) $required; } /** * One of a feature's human-readable strings, resolved at the moment it is drawn. * * @since 1.0.0 * * @param mixed $value A string, or something that can produce one. * @return string */ function wpbr_feature_text($value) { if (is_callable($value)) return (string) call_user_func($value); return is_scalar($value) ? (string) $value : ''; } /** * Register a feature an add-on provides. * * Free calls wpbr_feature_enabled() to decide whether to render a feature's own * surface or a placeholder. It never calls into the add-on directly, and it * never checks whether a particular plugin is active: a feature is present * because something registered it, not because of who that something was. * * @param string $id Stable identifier, e.g. 'woocommerce'. Never translated. * @param array $args { * @type string $title Shown wherever the feature is named. Translated by the caller. * @type string $description One sentence, for a placeholder. Translated by the caller. * @type int $api The WPBR_EXTENSION_API this add-on was written against. * @type callable $render Optional. The feature's default surface; the same * thing as $surfaces['default']. * @type array $surfaces Optional. Map of slot name => callable, naming the * slots in the free UI this feature fills. The slots * are listed in docs/EXTENDING.md; a name that is not * one of them is simply never rendered, because a * later release may add it. * } * @return bool True if registered; false if refused, in which case the refusal * is recorded so an administrator is told rather than left guessing. */ function wpbr_register_feature($id, array $args = array()) { $id = sanitize_key($id); if ($id === '') return false; $required = isset($args['api']) ? (int) $args['api'] : 1; if (!wpbr_extension_api_supports($required)) { // An add-on from the future. Refusing is the safe answer: it expects // guarantees this version does not make, and half-working is worse than // visibly absent. // // But refusing silently is worse than either. The customer installed and // paid for something and nothing happened; the fix - update the free // plugin - is one they would never guess. So the refusal is recorded and // wpbr_admin_warnings() tells them. $refused = wpbr_refused_features(); $refused[$id] = array( 'id' => $id, 'title' => isset($args['title']) ? $args['title'] : $id, 'required' => $required, ); $GLOBALS['wpbr_refused_features'] = $refused; return false; } // 'render' is the default surface under another name, so an add-on that only // has one place to draw does not have to know the slot vocabulary. $surfaces = array(); if (isset($args['surfaces']) && is_array($args['surfaces'])) { foreach ($args['surfaces'] as $slot => $callback) { $slot = sanitize_key($slot); if ($slot === '') continue; // Declaring a slot with no callable is meaningful, not an error: it is // how a lapsed licence says "this is mine, but I cannot draw it today", // which is what turns a hole in the screen into a placeholder. $surfaces[$slot] = is_callable($callback) ? $callback : null; } } if (isset($args['render']) && is_callable($args['render'])) { $surfaces['default'] = $args['render']; } $features = wpbr_registered_features(); $features[$id] = array( 'id' => $id, // A CALLABLE IS ACCEPTED HERE FOR THE SAME REASON IT IS ON A REFERENCE // PROVIDER'S LABEL. Features are registered on `wpbr_extensions_init`, // which Free fires from `plugins_loaded` — and translating there makes // WordPress 6.7+ log `_load_textdomain_just_in_time` on every request, // which is noise in somebody else's error log and reads as carelessness // in a plugin review. A plain string is still accepted, because an // add-on that does not need translating should not have to write a // function to say so. Resolve with `wpbr_feature_text()`. 'title' => isset($args['title']) ? $args['title'] : $id, 'description' => isset($args['description']) ? $args['description'] : '', 'api' => $required, 'render' => isset($surfaces['default']) ? $surfaces['default'] : null, 'surfaces' => $surfaces, ); $GLOBALS['wpbr_features'] = $features; return true; } /** * Every registered feature, keyed by id. * * Held in a global rather than an option: registration happens on every request, * from whatever add-on is active right now. Persisting it would mean a * deactivated add-on's features outliving it. */ function wpbr_registered_features() { if (!isset($GLOBALS['wpbr_features']) || !is_array($GLOBALS['wpbr_features'])) { $GLOBALS['wpbr_features'] = array(); } return $GLOBALS['wpbr_features']; } /** * Add-ons refused because they need a newer contract than this release honours. * * Held beside the registry and just as transient: the next request asks the * add-ons again, and an add-on that stopped asking stops being refused. */ function wpbr_refused_features() { if (!isset($GLOBALS['wpbr_refused_features']) || !is_array($GLOBALS['wpbr_refused_features'])) { $GLOBALS['wpbr_refused_features'] = array(); } return $GLOBALS['wpbr_refused_features']; } /** Whether a feature is available on this install right now. */ function wpbr_feature_enabled($id) { $features = wpbr_registered_features(); return isset($features[sanitize_key($id)]); } /** One registered feature, or null. */ function wpbr_feature($id) { $features = wpbr_registered_features(); $id = sanitize_key($id); return isset($features[$id]) ? $features[$id] : null; } /** * Render a feature's surface, or say plainly that it is not installed. * * The placeholder is the point. A screen that simply omits what it cannot do * leaves the reader wondering whether they are looking in the wrong place; one * that names the feature and says it is not installed answers the question. * * @param string $id Feature id. * @param string $fallback Optional sentence describing what is missing. Translated by the caller. */ function wpbr_render_feature($id, $fallback = '') { $feature = wpbr_feature($id); if ($feature && $feature['render']) { call_user_func($feature['render']); return; } $text = $fallback; if ($text === '' && $feature) { $text = wpbr_feature_text($feature['description']); } if ($text === '') { return; } echo '
'; echo '

' . esc_html($text) . '

'; echo '
'; } /** * Which features declare that they fill a named slot in the free UI. * * Declaring a slot and being able to draw it are two different things, and the * difference is the whole point. A lapsed licence leaves the feature registered * with the slot declared and the callback gone; that is what lets the screen say * "this is a Pro feature and it is not available right now" instead of quietly * rendering nothing where a panel used to be. * * @param string $slot Slot name, as listed in docs/EXTENDING.md. * @return array Features claiming that slot, keyed by id, in registration order. */ function wpbr_slot_features($slot) { $slot = sanitize_key($slot); $found = array(); foreach (wpbr_registered_features() as $id => $feature) { if (empty($feature['surfaces']) || !is_array($feature['surfaces'])) continue; if (array_key_exists($slot, $feature['surfaces'])) { $found[$id] = $feature; } } return $found; } /** * Render every feature that fills a named slot. * * With no add-on installed this prints nothing at all - no heading, no empty * box, no advertisement. A free site is a complete product (D3), and a screen * that lists what the reader has not bought is not a feature of it. * * @param string $slot Slot name. * @param array $context Passed to each surface callback; slot-specific. * @return int How many surfaces, real or placeholder, were rendered. */ function wpbr_render_slot($slot, array $context = array()) { $rendered = 0; foreach (wpbr_slot_features($slot) as $feature) { $callback = $feature['surfaces'][sanitize_key($slot)]; if ($callback) { // An add-on's own code, on a screen the free plugin owns. It is // called inside its own container so a callback that opens a tag and // forgets to close it cannot swallow the rest of the screen. echo '
'; call_user_func($callback, $context); echo '
'; $rendered++; continue; } /* translators: %s is the name of a Pro feature, e.g. "WooCommerce products". */ $template = __('%s is installed but not available right now. Its settings will reappear once it is active again.', 'wpbr-bulk-replace'); wpbr_render_feature($feature['id'], sprintf($template, wpbr_feature_text($feature['title']))); $rendered++; } return $rendered; } /** * Announce that the extension surface is ready. * * Fired once, after the plugin's own files are loaded and before any screen is * rendered. An add-on hooks this and registers what it provides; hooking * anything earlier means racing the definitions it depends on. * * @param int $api The WPBR_EXTENSION_API this release honours. */ function wpbr_announce_extension_surface() { /** * Fires when WP Bulk Replace is ready for add-ons to register features. * * ANNOUNCED ON `plugins_loaded` AT PRIORITY 10, AND THAT IS PART OF THE * CONTRACT RATHER THAN AN ACCIDENT. An add-on has to be listening by the * time this fires, so it must attach its listener either at file load or * from a `plugins_loaded` callback at a priority below 10. * * The failure this pins down is silent and total. An add-on that hooks * `plugins_loaded` at the default 10 runs *after* this one whenever its * file loaded second - which for a plugin named after this one is always, * because WordPress loads plugin files in filename order. Nothing errors; * the feature simply never registers, and the customer sees the "learn * about Pro" note on a site where they have bought Pro. * * @since 1.0.0 * @param int $api The extension API version this release honours. */ do_action('wpbr_extensions_init', WPBR_EXTENSION_API); } /** * Where "Learn about Pro" points. * * A filter rather than a constant so a distributor, or Pro itself once it is * installed, can send the reader somewhere more useful than the public page. */ function wpbr_pro_url() { /** * Filters the URL the Pro call to action links to. * * @since 1.0.0 * @param string $url The public Pro page. */ return (string) apply_filters('wpbr_pro_url', 'https://wpbulkreplace.com/pro'); } /** * The one place Free mentions Pro. * * Free is a complete product and does not advertise at the reader (D3), so this * is deliberately not a banner, not dismissible-because-it-nags, and not * repeated. It appears once, at the foot of the step where somebody is doing by * hand the thing Pro would remember - which is the only moment the offer is * information rather than interruption. * * It renders nothing at all when a feature providing this is already registered: * telling somebody who has bought Pro about Pro is the definition of a nag. */ function wpbr_render_pro_matching_cta() { if (wpbr_feature_enabled('learned_matching')) { return; } echo '
'; echo '

' . esc_html__('Make matching smarter over time with Pro', 'wpbr-bulk-replace') . '

'; echo '

' . esc_html__('Corrections you make here are used for this batch. WP Bulk Replace Pro remembers them, manages your abbreviations and ignore rules, and reuses that knowledge on every batch after this one.', 'wpbr-bulk-replace') . '

'; echo '

' . esc_html__('Learn about Pro', 'wpbr-bulk-replace') . '

'; echo '
'; }