Plugin Directory

Changeset 3695885


Ignore:
Timestamp:
09/14/2026 08:33:35 PM (7 weeks ago)
Author:
trainingbusinesspros
Message:

Update to version 4.8.1 from GitHub

Location:
groundhogg
Files:
20 added
4 deleted
66 edited
1 copied

Legend:

Unmodified
Added
Removed
  • groundhogg/tags/4.8.1/README.txt

    r3695315 r3695885  
    77Tested up to: 7.1
    88Requires PHP: 7.4
    9 Stable tag: 4.8
     9Stable tag: 4.8.1
    1010License: GPLv3
    1111License URI: https://www.gnu.org/licenses/gpl.md
    … …  
    391391
    392392== Changelog ==
     393
     394= 4.8.1 (2026-09-14) =
     395* ADDED "Create Flow" and "Activate Flow" abilities, so an AI agent can build a flow (funnel) from scratch, including branching steps, and turn it on. A new "List Step Types" ability lists the buildable step types and their settings so an agent can discover what's available before building.
     396* ADDED "List Replacement Codes" ability for discovering the merge tags available for use in emails.
     397* ADDED Add-ons can now register their own ability categories and abilities alongside Groundhogg's, and extend the contact and segment schemas with their own fields, so the WordPress Abilities API integration introduced in 4.8 isn't limited to what Groundhogg itself provides.
     398* TWEAKED Email-related abilities are now named consistently around "email template" (`create-email-template`, `update-email-template`, `get-email-template`) instead of a mix of "email" and "email-template".
     399* TWEAKED Ability descriptions now link out to relevant documentation for easier reference when working with an agent.
    393400
    394401= 4.8 (2026-09-10) =
  • groundhogg/tags/4.8.1/groundhogg.php

    r3695315 r3695885  
    44 * Plugin URI: https://groundhogg.io/?utm_source=wp-plugins&utm_campaign=plugin-uri&utm_medium=wp-dash
    55 * Description: CRM and marketing automation for WordPress
    6  * Version: 4.8
     6 * Version: 4.8.1
    77 * Author: Groundhogg Inc.
    88 * Author URI: https://groundhogg.io/?utm_source=wp-plugins&utm_campaign=author-uri&utm_medium=wp-dash
    … …  
    2727if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly
    2828
    29 define( 'GROUNDHOGG_VERSION', '4.8' );
    30 define( 'GROUNDHOGG_PREVIOUS_STABLE_VERSION', '4.7.2' );
     29define( 'GROUNDHOGG_VERSION', '4.8.1' );
     30define( 'GROUNDHOGG_PREVIOUS_STABLE_VERSION', '4.8' );
    3131
    3232define( 'GROUNDHOGG__FILE__', __FILE__ );
  • groundhogg/tags/4.8.1/includes/abilities/abilities.php

    r3695315 r3695885  
    1919use Groundhogg\Abilities\Db\Describe_Table;
    2020use Groundhogg\Abilities\Db\Query_Table;
     21use Groundhogg\Abilities\Funnels\Activate_Flow;
    2122use Groundhogg\Abilities\Funnels\Add_To_Flow;
     23use Groundhogg\Abilities\Funnels\Create_Flow;
     24use Groundhogg\Abilities\Funnels\Deactivate_Flow;
    2225use Groundhogg\Abilities\Funnels\List_Flows;
    23 use Groundhogg\Abilities\Emails\Create_Email;
     26use Groundhogg\Abilities\Funnels\List_Step_Types;
     27use Groundhogg\Abilities\Emails\Create_Email_Template;
     28use Groundhogg\Abilities\Emails\Get_Email_Template;
    2429use Groundhogg\Abilities\Emails\List_Email_Templates;
     30use Groundhogg\Abilities\Emails\List_Replacement_Codes;
    2531use Groundhogg\Abilities\Emails\List_Sender_Profiles;
    2632use Groundhogg\Abilities\Emails\Send_Composed_Email;
    2733use Groundhogg\Abilities\Emails\Send_Email_Template;
    28 use Groundhogg\Abilities\Emails\Update_Email;
     34use Groundhogg\Abilities\Emails\Update_Email_Template;
    2935use Groundhogg\Abilities\Reports\Get_Reports;
    3036use Groundhogg\Abilities\Reports\List_Report_Types;
    … …  
    3238use Groundhogg\Abilities\Utils\Upload_Media;
    3339
     40/**
     41 * Registers Groundhogg's own abilities/categories, and acts as the registry add-ons use to
     42 * register their own alongside them - Abilities::add_category() and Abilities::add_ability().
     43 *
     44 * Both must be called before the WP Abilities API actually registers anything - the
     45 * 'wp_abilities_api_categories_init'/'wp_abilities_api_init' hooks below, which normally fire on
     46 * 'init'. Groundhogg's own bootstrap runs on 'plugins_loaded' at priority 0 (see Plugin::init()),
     47 * so any add-on's own 'plugins_loaded' (default priority 10) or 'init' callback is early enough.
     48 * Registering from that far out just queues the category/class; if a call ever comes in after the
     49 * relevant hook has already fired, it's applied immediately instead of being silently dropped.
     50 *
     51 * Example - a 3rd-party add-on registering its own category and ability:
     52 *
     53 *     add_action( 'plugins_loaded', function () {
     54 *
     55 *         Abilities::add_category( 'my-addon', [
     56 *             'label'       => __( 'My Add-on', 'my-addon' ),
     57 *             'description' => __( 'Abilities provided by My Add-on.', 'my-addon' ),
     58 *         ] );
     59 *
     60 *         Abilities::add_ability( My_Addon\Abilities\Do_The_Thing::class );
     61 *     } );
     62 *
     63 * Do_The_Thing must extend Groundhogg\Abilities\Ability just like Groundhogg's own abilities -
     64 * it's instantiated the same way, which is what actually calls wp_register_ability().
     65 */
    3466class Abilities {
     67
     68        /**
     69         * Ability classes registered by add-ons via self::add_ability(), instantiated alongside
     70         * Groundhogg's own in register_abilities().
     71         *
     72         * @var string[]
     73         */
     74        protected static array $extra_abilities = [];
     75
     76        /**
     77         * Ability categories registered by add-ons via self::add_category(), registered alongside
     78         * Groundhogg's own in register_categories().
     79         *
     80         * @var array<string, array>
     81         */
     82        protected static array $extra_categories = [];
    3583
    3684        public function __construct() {
    … …  
    5199        }
    52100
     101        /**
     102         * Register an additional ability category, so a 3rd-party add-on's abilities can be grouped
     103         * under their own category instead of one of Groundhogg's. See the class docblock for a full
     104         * example and the timing requirement.
     105         *
     106         * @param string $slug the category slug, e.g. "my-addon"
     107         * @param array  $args ['label' => string, 'description' => string], same shape
     108         *                     wp_register_ability_category() itself takes
     109         *
     110         * @return void
     111         */
     112        public static function add_category( string $slug, array $args ) {
     113
     114                self::$extra_categories[ $slug ] = $args;
     115
     116                // The categories hook already fired - register it right away rather than dropping it.
     117                if ( did_action( 'wp_abilities_api_categories_init' ) && function_exists( 'wp_register_ability_category' ) ) {
     118                        wp_register_ability_category( $slug, $args );
     119                }
     120        }
     121
     122        /**
     123         * Register an additional ability class, instantiated the same way Groundhogg's own abilities
     124         * are - which is what actually calls wp_register_ability(). See the class docblock for a full
     125         * example and the timing requirement.
     126         *
     127         * @param string $class fully-qualified class name, extending Groundhogg\Abilities\Ability
     128         *
     129         * @return void
     130         */
     131        public static function add_ability( string $class ) {
     132
     133                self::$extra_abilities[] = $class;
     134
     135                // The abilities hook already fired - register it right away rather than dropping it.
     136                if ( did_action( 'wp_abilities_api_init' ) ) {
     137                        new $class();
     138                }
     139        }
     140
    53141        public function register_categories() {
    54142
    … …  
    97185                        'description' => __( 'General-purpose utilities that support the other categories but aren\'t specific to any one of them.', 'groundhogg' ),
    98186                ] );
     187
     188                foreach ( self::$extra_categories as $slug => $args ) {
     189                        wp_register_ability_category( $slug, $args );
     190                }
    99191        }
    100192
    101193        public function register_abilities() {
    102194
    103                 foreach ( [
     195                $abilities = array_merge( [
    104196                        Get_Contact::class,
    105197                        Create_Contact::class,
    … …  
    114206                        List_Contact_Notes::class,
    115207                        List_Email_Templates::class,
     208                        Get_Email_Template::class,
    116209                        List_Sender_Profiles::class,
    117                         Create_Email::class,
    118                         Update_Email::class,
     210                        Create_Email_Template::class,
     211                        Update_Email_Template::class,
     212                        List_Replacement_Codes::class,
    119213                        Send_Composed_Email::class,
    120214                        Send_Email_Template::class,
    … …  
    124218                        Cancel_Broadcast::class,
    125219                        List_Flows::class,
     220                        List_Step_Types::class,
     221                        Create_Flow::class,
     222                        Activate_Flow::class,
     223                        Deactivate_Flow::class,
    126224                        Add_To_Flow::class,
    127225                        List_Report_Types::class,
    … …  
    130228                        Query_Table::class,
    131229                        Upload_Media::class,
    132                 ] as $ability ){
    133 
    134                         $ability = new $ability();
    135 
    136                 }
    137 
     230                ], self::$extra_abilities );
     231
     232                foreach ( $abilities as $ability ) {
     233                        new $ability();
     234                }
    138235        }
    139236}
  • groundhogg/tags/4.8.1/includes/abilities/ability.php

    r3695315 r3695885  
    55abstract class Ability {
    66
    7         protected const string NAME       = '';
    8         protected const string CATEGORY   = '';
    9         protected const string CAPABILITY = '';
     7        protected const NAME       = '';
     8        protected const CATEGORY   = '';
     9        protected const CAPABILITY = '';
    1010
    11         protected const bool PUBLIC      = true;
    12         protected const bool READONLY    = false;
    13         protected const bool DESTRUCTIVE = false;
    14         protected const bool IDEMPOTENT  = false;
     11        protected const PUBLIC      = true;
     12        protected const READONLY    = false;
     13        protected const DESTRUCTIVE = false;
     14        protected const IDEMPOTENT  = false;
    1515
    1616        /**
  • groundhogg/tags/4.8.1/includes/abilities/broadcasts/cancel-broadcast.php

    r3695315 r3695885  
    2323class Cancel_Broadcast extends Ability {
    2424
    25         protected const string NAME       = 'groundhogg/cancel-broadcast';
    26         protected const string CATEGORY   = 'groundhogg-broadcasts';
    27         protected const string CAPABILITY = 'cancel_broadcasts';
     25        protected const NAME       = 'groundhogg/cancel-broadcast';
     26        protected const CATEGORY   = 'groundhogg-broadcasts';
     27        protected const CAPABILITY = 'cancel_broadcasts';
    2828
    29         protected const bool READONLY    = false;
    30         protected const bool DESTRUCTIVE = true;
    31         protected const bool IDEMPOTENT  = true;
     29        protected const READONLY    = false;
     30        protected const DESTRUCTIVE = true;
     31        protected const IDEMPOTENT  = true;
    3232
    3333        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/broadcasts/get-broadcast.php

    r3695315 r3695885  
    1616class Get_Broadcast extends Ability {
    1717
    18         protected const string NAME       = 'groundhogg/get-broadcast';
    19         protected const string CATEGORY   = 'groundhogg-broadcasts';
    20         protected const string CAPABILITY = 'view_broadcasts';
     18        protected const NAME       = 'groundhogg/get-broadcast';
     19        protected const CATEGORY   = 'groundhogg-broadcasts';
     20        protected const CAPABILITY = 'view_broadcasts';
    2121
    22         protected const bool READONLY   = true;
    23         protected const bool IDEMPOTENT = true;
     22        protected const READONLY   = true;
     23        protected const IDEMPOTENT = true;
    2424
    2525        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/broadcasts/list-broadcasts.php

    r3695315 r3695885  
    3737class List_Broadcasts extends Ability {
    3838
    39         protected const string NAME       = 'groundhogg/list-broadcasts';
    40         protected const string CATEGORY   = 'groundhogg-broadcasts';
    41         protected const string CAPABILITY = 'view_broadcasts';
    42 
    43         protected const bool READONLY   = true;
    44         protected const bool IDEMPOTENT = true;
     39        protected const NAME       = 'groundhogg/list-broadcasts';
     40        protected const CATEGORY   = 'groundhogg-broadcasts';
     41        protected const CAPABILITY = 'view_broadcasts';
     42
     43        protected const READONLY   = true;
     44        protected const IDEMPOTENT = true;
    4545
    4646        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/broadcasts/send-email-broadcast.php

    r3695315 r3695885  
    4141class Send_Email_Broadcast extends Ability {
    4242
    43         protected const string NAME       = 'groundhogg/send-email-broadcast';
    44         protected const string CATEGORY   = 'groundhogg-broadcasts';
    45         protected const string CAPABILITY = 'schedule_broadcasts';
    46 
    47         protected const bool READONLY    = false;
    48         protected const bool DESTRUCTIVE = true;
    49         protected const bool IDEMPOTENT  = false;
     43        protected const NAME       = 'groundhogg/send-email-broadcast';
     44        protected const CATEGORY   = 'groundhogg-broadcasts';
     45        protected const CAPABILITY = 'schedule_broadcasts';
     46
     47        protected const READONLY    = false;
     48        protected const DESTRUCTIVE = true;
     49        protected const IDEMPOTENT  = false;
    5050
    5151        protected function get_args(): array {
    … …  
    195195                }
    196196
     197                // The plain array form, not to_contact_query()'s live query object -
     198                // this gets stored (Broadcast::schedule()'s 'query' arg below) and, for
     199                // a "dynamic" segment_type, re-run later against whatever then matches;
     200                // a live query object (and anything a filter attached to it) can't
     201                // survive that round trip.
    197202                $query = Segment_Schema::to_query( $input );
    198203
  • groundhogg/tags/4.8.1/includes/abilities/campaigns/list-campaigns.php

    r3695315 r3695885  
    99/**
    1010 * Lists the campaigns defined in Groundhogg (Contacts > Campaigns in the admin), so a caller
    11  * can discover valid campaign ids/names to pass as create-email's/update-email's campaigns param,
    12  * or to filter flows/broadcasts by campaign, without guessing.
     11 * can discover valid campaign ids/names to pass as create-email-template's/update-email-template's
     12 * campaigns param, or to filter flows/broadcasts/emails by campaign, without guessing.
    1313 */
    1414class List_Campaigns extends Ability {
    1515
    16         protected const string NAME       = 'groundhogg/list-campaigns';
    17         protected const string CATEGORY   = 'groundhogg-campaigns';
    18         protected const string CAPABILITY = 'manage_campaigns';
     16        protected const NAME       = 'groundhogg/list-campaigns';
     17        protected const CATEGORY   = 'groundhogg-campaigns';
     18        protected const CAPABILITY = 'manage_campaigns';
    1919
    20         protected const bool READONLY   = true;
    21         protected const bool IDEMPOTENT = true;
     20        protected const READONLY   = true;
     21        protected const IDEMPOTENT = true;
    2222
    2323        protected function get_args(): array {
    … …  
    2525                return [
    2626                        'label'       => __( 'List Campaigns', 'groundhogg' ),
    27                         'description' => __( 'List the campaigns defined in Groundhogg. Use the returned id or name with groundhogg/create-email\'s or groundhogg/update-email\'s campaigns param.', 'groundhogg' ),
     27                        'description' => __( 'List the campaigns defined in Groundhogg. Use the returned id or name with groundhogg/create-email-template\'s or groundhogg/update-email-template\'s campaigns param.', 'groundhogg' ),
    2828
    2929                        'input_schema' => [
  • groundhogg/tags/4.8.1/includes/abilities/contacts/add-contact-note.php

    r3695315 r3695885  
    2020class Add_Contact_Note extends Ability {
    2121
    22         protected const string NAME       = 'groundhogg/add-contact-note';
    23         protected const string CATEGORY   = 'groundhogg-contacts';
    24         protected const string CAPABILITY = 'add_notes';
     22        protected const NAME       = 'groundhogg/add-contact-note';
     23        protected const CATEGORY   = 'groundhogg-contacts';
     24        protected const CAPABILITY = 'add_notes';
    2525
    26         protected const bool READONLY    = false;
    27         protected const bool DESTRUCTIVE = false;
    28         protected const bool IDEMPOTENT  = false;
     26        protected const READONLY    = false;
     27        protected const DESTRUCTIVE = false;
     28        protected const IDEMPOTENT  = false;
    2929
    3030        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/contacts/create-contact.php

    r3695315 r3695885  
    3434        use Has_Owner_Validation;
    3535
    36         protected const string NAME       = 'groundhogg/create-contact';
    37         protected const string CATEGORY   = 'groundhogg-contacts';
    38         protected const string CAPABILITY = 'add_contacts';
    39 
    40         protected const bool READONLY    = false;
    41         protected const bool DESTRUCTIVE = false;
    42         protected const bool IDEMPOTENT  = true;
     36        protected const NAME       = 'groundhogg/create-contact';
     37        protected const CATEGORY   = 'groundhogg-contacts';
     38        protected const CAPABILITY = 'add_contacts';
     39
     40        protected const READONLY    = false;
     41        protected const DESTRUCTIVE = false;
     42        protected const IDEMPOTENT  = true;
    4343
    4444        protected function get_args(): array {
    … …  
    8888                                                'items'       => [
    8989                                                        'type' => 'string',
    90                                                         'enum' => [ 'tags', 'meta' ],
     90                                                        'enum' => Contact_Schema::expand_options(),
    9191                                                ],
    9292                                                'default'     => [ 'tags' ],
    93                                                 'description' => __( 'Optional extra sections to expand on the returned contact. Available: tags, meta.', 'groundhogg' ),
     93                                                'description' => __( 'Optional extra sections to expand on the returned contact. Available: tags, meta, plus any sections an installed add-on has registered.', 'groundhogg' ),
    9494                                        ],
    9595                                ],
  • groundhogg/tags/4.8.1/includes/abilities/contacts/get-contact.php

    r3695315 r3695885  
    1010class Get_Contact extends Ability {
    1111
    12         protected const string NAME       = 'groundhogg/get-contact';
    13         protected const string CATEGORY   = 'groundhogg-contacts';
    14         protected const string CAPABILITY = 'view_contacts';
     12        protected const NAME       = 'groundhogg/get-contact';
     13        protected const CATEGORY   = 'groundhogg-contacts';
     14        protected const CAPABILITY = 'view_contacts';
    1515
    16         protected const bool READONLY   = true;
    17         protected const bool IDEMPOTENT = true;
     16        protected const READONLY   = true;
     17        protected const IDEMPOTENT = true;
    1818
    1919        protected function get_args(): array {
    … …  
    4646                                                'items'       => [
    4747                                                        'type' => 'string',
    48                                                         'enum' => [ 'tags', 'meta' ],
     48                                                        'enum' => Contact_Schema::expand_options(),
    4949                                                ],
    5050                                                'default'     => [ 'tags' ],
    51                                                 'description' => __( 'Optional extra sections to expand beyond the standard contact fields. Available: tags, meta (raw custom field/meta values - see groundhogg/list-custom-fields to interpret them).', 'groundhogg' ),
     51                                                'description' => __( 'Optional extra sections to expand beyond the standard contact fields. Available: tags, meta (raw custom field/meta values - see groundhogg/list-custom-fields to interpret them), plus any sections an installed add-on has registered.', 'groundhogg' ),
    5252                                        ],
    5353                                ],
  • groundhogg/tags/4.8.1/includes/abilities/contacts/list-contact-notes.php

    r3695315 r3695885  
    2626class List_Contact_Notes extends Ability {
    2727
    28         protected const string NAME       = 'groundhogg/list-contact-notes';
    29         protected const string CATEGORY   = 'groundhogg-contacts';
    30         protected const string CAPABILITY = 'view_notes';
     28        protected const NAME       = 'groundhogg/list-contact-notes';
     29        protected const CATEGORY   = 'groundhogg-contacts';
     30        protected const CAPABILITY = 'view_notes';
    3131
    32         protected const bool READONLY   = true;
    33         protected const bool IDEMPOTENT = true;
     32        protected const READONLY   = true;
     33        protected const IDEMPOTENT = true;
    3434
    3535        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/contacts/list-custom-fields.php

    r3695315 r3695885  
    1515class List_Custom_Fields extends Ability {
    1616
    17         protected const string NAME       = 'groundhogg/list-custom-fields';
    18         protected const string CATEGORY   = 'groundhogg-contacts';
    19         protected const string CAPABILITY = 'edit_contacts';
     17        protected const NAME       = 'groundhogg/list-custom-fields';
     18        protected const CATEGORY   = 'groundhogg-contacts';
     19        protected const CAPABILITY = 'edit_contacts';
    2020
    21         protected const bool READONLY   = true;
    22         protected const bool IDEMPOTENT = true;
     21        protected const READONLY   = true;
     22        protected const IDEMPOTENT = true;
    2323
    2424        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/contacts/list-owners.php

    r3695315 r3695885  
    1414class List_Owners extends Ability {
    1515
    16         protected const string NAME       = 'groundhogg/list-owners';
    17         protected const string CATEGORY   = 'groundhogg-contacts';
    18         protected const string CAPABILITY = 'view_contacts';
     16        protected const NAME       = 'groundhogg/list-owners';
     17        protected const CATEGORY   = 'groundhogg-contacts';
     18        protected const CAPABILITY = 'view_contacts';
    1919
    20         protected const bool READONLY   = true;
    21         protected const bool IDEMPOTENT = true;
     20        protected const READONLY   = true;
     21        protected const IDEMPOTENT = true;
    2222
    2323        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/contacts/list-saved-searches.php

    r3695315 r3695885  
    1313class List_Saved_Searches extends Ability {
    1414
    15         protected const string NAME       = 'groundhogg/list-saved-searches';
    16         protected const string CATEGORY   = 'groundhogg-contacts';
    17         protected const string CAPABILITY = 'view_contacts';
     15        protected const NAME       = 'groundhogg/list-saved-searches';
     16        protected const CATEGORY   = 'groundhogg-contacts';
     17        protected const CAPABILITY = 'view_contacts';
    1818
    19         protected const bool READONLY   = true;
    20         protected const bool IDEMPOTENT = true;
     19        protected const READONLY   = true;
     20        protected const IDEMPOTENT = true;
    2121
    2222        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/contacts/search-contacts.php

    r3695315 r3695885  
    66use Groundhogg\Abilities\Schemas\Contact_Schema;
    77use Groundhogg\Abilities\Schemas\Segment_Schema;
    8 use Groundhogg\Contact_Query;
    98use Throwable;
    109use WP_Error;
    … …  
    1211class Search_Contacts extends Ability {
    1312
    14         protected const string NAME       = 'groundhogg/search-contacts';
    15         protected const string CATEGORY   = 'groundhogg-contacts';
    16         protected const string CAPABILITY = 'view_contacts';
     13        protected const NAME       = 'groundhogg/search-contacts';
     14        protected const CATEGORY   = 'groundhogg-contacts';
     15        protected const CAPABILITY = 'view_contacts';
    1716
    18         protected const bool READONLY   = true;
    19         protected const bool IDEMPOTENT = true;
     17        protected const READONLY   = true;
     18        protected const IDEMPOTENT = true;
    2019
    2120        protected function get_args(): array {
    … …  
    3332                                                'items'       => [
    3433                                                        'type' => 'string',
    35                                                         'enum' => [ 'tags', 'meta' ],
     34                                                        // 'sql' is search-contacts' own debugging addition, not
     35                                                        // part of Contact_Schema::expand_options() (it's about
     36                                                        // the query as a whole, not a per-contact section, and
     37                                                        // wouldn't make sense on get-contact/create-contact/
     38                                                        // update-contact's single-row fetches).
     39                                                        'enum' => array_merge( Contact_Schema::expand_options(), [ 'sql' ] ),
    3640                                                ],
    3741                                                'default'     => [ 'tags' ],
    38                                                 'description' => __( 'Optional extra sections to expand on each contact, beyond the standard fields. Available: tags, meta (raw custom field/meta values - see groundhogg/list-custom-fields to interpret them).', 'groundhogg' ),
     42                                                'description' => __( 'Optional extra sections to expand, beyond the standard fields. Per-contact: tags, meta (raw custom field/meta values - see groundhogg/list-custom-fields to interpret them), plus any sections an installed add-on has registered. Query-level (not per-contact): "sql" adds a top-level `sql` field with the raw SQL Contact_Query built for this search - useful for debugging why a search matched (or didn\'t match) what was expected. Note it reflects the modern query builder\'s attempt specifically; if that attempt threw and Contact_Query silently fell back to its legacy query engine, this SQL will NOT be what actually ran.', 'groundhogg' ),
    3943                                        ],
    4044                                        'limit' => [
    … …  
    6569                                                'items' => Contact_Schema::get_schema(),
    6670                                        ],
     71                                        'sql' => [
     72                                                'type'        => 'string',
     73                                                'description' => __( 'Only present when "sql" is passed in expand. The raw SQL Contact_Query built for this search - see the `expand` input\'s description for a caveat about legacy-query fallback.', 'groundhogg' ),
     74                                        ],
    6775                                ],
    6876                        ],
    … …  
    7583                $offset = ! empty( $input['offset'] ) ? absint( $input['offset'] ) : 0;
    7684
    77                 $query_vars = Segment_Schema::to_query( $input );
     85                // Executes immediately below, so a live query object (to_contact_query())
     86                // rather than the plain array to_query() gives - lets an add-on hook
     87                // groundhogg/segment_schema/contact_query and manipulate the query
     88                // directly (joins, raw where conditions), and lets pagination be applied
     89                // here by just chaining onto it.
     90                $contact_query = Segment_Schema::to_contact_query( $input );
    7891
    79                 if ( is_wp_error( $query_vars ) ) {
    80                         return $query_vars;
     92                if ( is_wp_error( $contact_query ) ) {
     93                        return $contact_query;
    8194                }
    8295
    83                 $query_vars['number']     = $limit;
    84                 $query_vars['offset']     = $offset;
    85                 $query_vars['found_rows'] = true;
     96                $contact_query->setLimit( $limit )->setOffset( $offset )->setFoundRows( true );
    8697
    8798                try {
    88                         $contact_query = new Contact_Query();
    89                         $results       = $contact_query->query( $query_vars );
     99                        $results = $contact_query->query();
    90100                } catch ( Throwable $e ) {
    91101                        // Contact_Query's modern query building already falls back to a legacy query on
    … …  
    101111                }, $results );
    102112
    103                 return [
     113                $output = [
    104114                        'total_items' => $contact_query->found_items,
    105115                        'contacts'    => $contacts,
    106116                ];
     117
     118                if ( in_array( 'sql', $expand, true ) ) {
     119                        // Called after query() rather than before: get_sql()'s own
     120                        // maybe_setup_query() call is a no-op by this point (already run), so
     121                        // this reflects the exact query state query() actually built - not a
     122                        // fresh, possibly-different build. See the `expand` input's
     123                        // description for the legacy-fallback caveat.
     124                        $output['sql'] = $contact_query->get_sql();
     125                }
     126
     127                return $output;
    107128        }
    108129}
  • groundhogg/tags/4.8.1/includes/abilities/contacts/update-contact.php

    r3695315 r3695885  
    2929        use Has_Owner_Validation;
    3030
    31         protected const string NAME       = 'groundhogg/update-contact';
    32         protected const string CATEGORY   = 'groundhogg-contacts';
    33         protected const string CAPABILITY = 'edit_contacts';
    34 
    35         protected const bool READONLY    = false;
    36         protected const bool DESTRUCTIVE = false;
    37         protected const bool IDEMPOTENT  = true;
     31        protected const NAME       = 'groundhogg/update-contact';
     32        protected const CATEGORY   = 'groundhogg-contacts';
     33        protected const CAPABILITY = 'edit_contacts';
     34
     35        protected const READONLY    = false;
     36        protected const DESTRUCTIVE = false;
     37        protected const IDEMPOTENT  = true;
    3838
    3939        protected function get_args(): array {
    … …  
    9393                                                'items'       => [
    9494                                                        'type' => 'string',
    95                                                         'enum' => [ 'tags', 'meta' ],
     95                                                        'enum' => Contact_Schema::expand_options(),
    9696                                                ],
    9797                                                'default'     => [ 'tags' ],
    98                                                 'description' => __( 'Optional extra sections to expand on the returned contact. Available: tags, meta.', 'groundhogg' ),
     98                                                'description' => __( 'Optional extra sections to expand on the returned contact. Available: tags, meta, plus any sections an installed add-on has registered.', 'groundhogg' ),
    9999                                        ],
    100100                                ],
  • groundhogg/tags/4.8.1/includes/abilities/db/describe-table.php

    r3695315 r3695885  
    1515class Describe_Table extends Ability {
    1616
    17         protected const string NAME       = 'groundhogg/describe-table';
    18         protected const string CATEGORY   = 'groundhogg-db';
    19         protected const string CAPABILITY = 'manage_options';
     17        protected const NAME       = 'groundhogg/describe-table';
     18        protected const CATEGORY   = 'groundhogg-db';
     19        protected const CAPABILITY = 'manage_options';
    2020
    21         protected const bool READONLY   = true;
    22         protected const bool IDEMPOTENT = true;
     21        protected const READONLY   = true;
     22        protected const IDEMPOTENT = true;
    2323
    2424        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/db/query-table.php

    r3695315 r3695885  
    4040class Query_Table extends Ability {
    4141
    42         protected const string NAME       = 'groundhogg/query-table';
    43         protected const string CATEGORY   = 'groundhogg-db';
    44         protected const string CAPABILITY = 'manage_options';
    45 
    46         protected const bool READONLY   = true;
    47         protected const bool IDEMPOTENT = true;
     42        protected const NAME       = 'groundhogg/query-table';
     43        protected const CATEGORY   = 'groundhogg-db';
     44        protected const CAPABILITY = 'manage_options';
     45
     46        protected const READONLY   = true;
     47        protected const IDEMPOTENT = true;
    4848
    4949        /**
  • groundhogg/tags/4.8.1/includes/abilities/emails/list-email-templates.php

    r3695315 r3695885  
    1919 * is almost never needed just to pick which email to send, so it is only
    2020 * returned when explicitly requested via `expand`. When expanding content, keep
    21  * `limit` small.
     21 * `limit` small, or use groundhogg/get-email-template to fetch one email's full
     22 * body directly once you have its id.
    2223 *
    2324 * `campaigns` filters via the generic object_relationships-backed 'related'
    2425 * query var (DB::query()) - an email is the primary/parent side of its
    2526 * relationship to a campaign, same direction Email::get_related_objects('campaign')
    26  * reads (see groundhogg/create-email's and groundhogg/update-email's campaigns param).
     27 * reads (see groundhogg/create-email-template's and groundhogg/update-email-template's
     28 * campaigns param).
    2729 */
    2830class List_Email_Templates extends Ability {
    2931
    30         protected const string NAME       = 'groundhogg/list-email-templates';
    31         protected const string CATEGORY   = 'groundhogg-email';
    32         protected const string CAPABILITY = 'view_emails';
     32        protected const NAME       = 'groundhogg/list-email-templates';
     33        protected const CATEGORY   = 'groundhogg-email';
     34        protected const CAPABILITY = 'view_emails';
    3335
    34         protected const bool READONLY   = true;
    35         protected const bool IDEMPOTENT = true;
     36        protected const READONLY   = true;
     37        protected const IDEMPOTENT = true;
    3638
    3739        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/emails/list-sender-profiles.php

    r3695315 r3695885  
    2222class List_Sender_Profiles extends Ability {
    2323
    24         protected const string NAME       = 'groundhogg/list-sender-profiles';
    25         protected const string CATEGORY   = 'groundhogg-email';
    26         protected const string CAPABILITY = 'send_emails';
     24        protected const NAME       = 'groundhogg/list-sender-profiles';
     25        protected const CATEGORY   = 'groundhogg-email';
     26        protected const CAPABILITY = 'send_emails';
    2727
    28         protected const bool READONLY   = true;
    29         protected const bool IDEMPOTENT = true;
     28        protected const READONLY   = true;
     29        protected const IDEMPOTENT = true;
    3030
    3131        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/emails/send-composed-email.php

    r3695315 r3695885  
    4343class Send_Composed_Email extends Ability {
    4444
    45         protected const string NAME       = 'groundhogg/send-composed-email';
    46         protected const string CATEGORY   = 'groundhogg-email';
    47         protected const string CAPABILITY = 'send_emails';
    48 
    49         protected const bool READONLY    = false;
    50         protected const bool DESTRUCTIVE = true;
    51         protected const bool IDEMPOTENT  = false;
     45        protected const NAME       = 'groundhogg/send-composed-email';
     46        protected const CATEGORY   = 'groundhogg-email';
     47        protected const CAPABILITY = 'send_emails';
     48
     49        protected const READONLY    = false;
     50        protected const DESTRUCTIVE = true;
     51        protected const IDEMPOTENT  = false;
    5252
    5353        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/emails/send-email-template.php

    r3695315 r3695885  
    3636class Send_Email_Template extends Ability {
    3737
    38         protected const string NAME       = 'groundhogg/send-email-template';
    39         protected const string CATEGORY   = 'groundhogg-email';
    40         protected const string CAPABILITY = 'send_emails';
    41 
    42         protected const bool READONLY    = false;
    43         protected const bool DESTRUCTIVE = true;
    44         protected const bool IDEMPOTENT  = false;
     38        protected const NAME       = 'groundhogg/send-email-template';
     39        protected const CATEGORY   = 'groundhogg-email';
     40        protected const CAPABILITY = 'send_emails';
     41
     42        protected const READONLY    = false;
     43        protected const DESTRUCTIVE = true;
     44        protected const IDEMPOTENT  = false;
    4545
    4646        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/funnels/add-to-flow.php

    r3695315 r3695885  
    3434class Add_To_Flow extends Ability {
    3535
    36         protected const string NAME       = 'groundhogg/add-to-flow';
    37         protected const string CATEGORY   = 'groundhogg-funnels';
    38         protected const string CAPABILITY = 'start_flows';
    39 
    40         protected const bool READONLY    = false;
    41         protected const bool DESTRUCTIVE = true;
    42         protected const bool IDEMPOTENT  = false;
     36        protected const NAME       = 'groundhogg/add-to-flow';
     37        protected const CATEGORY   = 'groundhogg-funnels';
     38        protected const CAPABILITY = 'start_flows';
     39
     40        protected const READONLY    = false;
     41        protected const DESTRUCTIVE = true;
     42        protected const IDEMPOTENT  = false;
    4343
    4444        protected function get_args(): array {
    … …  
    173173                }
    174174
     175                // The plain array form, not to_contact_query()'s live query object -
     176                // this gets stored and re-run in background batches (Background_Tasks::
     177                // add_contacts_to_funnel() below); a live query object (and anything a
     178                // filter attached to it) can't survive that round trip.
    175179                $query = Segment_Schema::to_query( $input );
    176180
  • groundhogg/tags/4.8.1/includes/abilities/funnels/list-flows.php

    r3695315 r3695885  
    2121class List_Flows extends Ability {
    2222
    23         protected const string NAME       = 'groundhogg/list-flows';
    24         protected const string CATEGORY   = 'groundhogg-funnels';
    25         protected const string CAPABILITY = 'view_funnels';
     23        protected const NAME       = 'groundhogg/list-flows';
     24        protected const CATEGORY   = 'groundhogg-funnels';
     25        protected const CAPABILITY = 'view_funnels';
    2626
    27         protected const bool READONLY   = true;
    28         protected const bool IDEMPOTENT = true;
     27        protected const READONLY   = true;
     28        protected const IDEMPOTENT = true;
    2929
    3030        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/reports/get-reports.php

    r3695315 r3695885  
    3737class Get_Reports extends Ability {
    3838
    39         protected const string NAME       = 'groundhogg/get-reports';
    40         protected const string CATEGORY   = 'groundhogg-reports';
    41         protected const string CAPABILITY = 'view_reports';
    42 
    43         protected const bool READONLY   = true;
    44         protected const bool IDEMPOTENT = true;
     39        protected const NAME       = 'groundhogg/get-reports';
     40        protected const CATEGORY   = 'groundhogg-reports';
     41        protected const CAPABILITY = 'view_reports';
     42
     43        protected const READONLY   = true;
     44        protected const IDEMPOTENT = true;
    4545
    4646        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/reports/list-report-types.php

    r3695315 r3695885  
    1616class List_Report_Types extends Ability {
    1717
    18         protected const string NAME       = 'groundhogg/list-report-types';
    19         protected const string CATEGORY   = 'groundhogg-reports';
    20         protected const string CAPABILITY = 'view_reports';
     18        protected const NAME       = 'groundhogg/list-report-types';
     19        protected const CATEGORY   = 'groundhogg-reports';
     20        protected const CAPABILITY = 'view_reports';
    2121
    22         protected const bool READONLY   = true;
    23         protected const bool IDEMPOTENT = true;
     22        protected const READONLY   = true;
     23        protected const IDEMPOTENT = true;
    2424
    2525        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/schemas/contact-schema.php

    r3695315 r3695885  
    55use DateTime;
    66use Exception;
     7use Groundhogg\Abilities\Traits\Extensible_Schema;
    78use Groundhogg\Contact;
    89use Groundhogg\Preferences;
    … …  
    2425 * per install. Use the groundhogg/list-custom-fields ability to look up what each meta
    2526 * key actually means (label, type, group).
     27 *
     28 * Extensible via the Extensible_Schema trait, so an add-on can expose additional
     29 * read-only fields on groundhogg/get-contact and groundhogg/search-contacts
     30 * results without modifying this class or either of those abilities - one call
     31 * registers a new bounded-but-optional property (schema, expand option, and the
     32 * callback that computes it), the same "only when asked for" treatment
     33 * 'tags'/'meta' get above:
     34 *
     35 *     Contact_Schema::extend(
     36 *         'ltv',
     37 *         __( 'Lifetime value: total spend across all WooCommerce orders.', 'my-plugin' ),
     38 *         function ( Contact $contact ) {
     39 *             return (float) wc_get_customer_total_spent( $contact->get_user_id() );
     40 *         },
     41 *         'number'
     42 *     );
     43 *
     44 * Call this once (e.g. on `init`, after confirming both Groundhogg and the
     45 * dependency it needs are active) - before any ability builds its input/output
     46 * schema. See Extensible_Schema's own docblock for what extend() can't express
     47 * and the three underlying filters (`groundhogg/contact_schema/properties`,
     48 * `groundhogg/contact_schema/transform`, `groundhogg/contact_schema/expand_options`)
     49 * available for those cases.
    2650 */
    2751class Contact_Schema extends Schema {
    2852
     53        use Extensible_Schema;
     54
     55        protected static function extension_hook_prefix(): string {
     56                return 'contact_schema';
     57        }
     58
     59        protected static function builtin_property_keys(): array {
     60                return [ 'id', 'email', 'first_name', 'last_name', 'date_created', 'optin_status', 'tags', 'meta' ];
     61        }
     62
    2963        public static function get_schema(): array {
     64
     65                $properties = [
     66                        'id' => [
     67                                'type' => 'integer',
     68                        ],
     69                        'email' => [
     70                                'type'   => 'string',
     71                                'format' => 'email',
     72                        ],
     73                        'first_name' => [
     74                                'type' => 'string',
     75                        ],
     76                        'last_name' => [
     77                                'type' => 'string',
     78                        ],
     79                        'date_created' => [
     80                                'type'        => 'string',
     81                                'format'      => 'date-time',
     82                                'description' => __( 'When the contact was created.', 'groundhogg' ),
     83                        ],
     84                        'optin_status' => [
     85                                'type'        => 'object',
     86                                'description' => __( 'Whether/how the contact can be marketed to.', 'groundhogg' ),
     87                                'properties'  => [
     88                                        'value' => [
     89                                                'type'        => 'integer',
     90                                                'description' => __( 'The raw Groundhogg\Preferences status constant.', 'groundhogg' ),
     91                                        ],
     92                                        'label' => [
     93                                                'type' => 'string',
     94                                        ],
     95                                ],
     96                        ],
     97                        'tags' => [
     98                                'type'        => 'array',
     99                                'description' => __( 'Only present when "tags" is passed in include.', 'groundhogg' ),
     100                                'items'       => Tag_Schema::get_schema(),
     101                        ],
     102                        'meta' => [
     103                                'type'                 => 'object',
     104                                'description'          => __( 'Only present when "meta" is passed in include. Raw meta key => value pairs, including custom fields. See groundhogg/list-custom-fields to map keys to labels.', 'groundhogg' ),
     105                                'additionalProperties' => true,
     106                        ],
     107                ];
    30108
    31109                return [
    32110                        'type'       => 'object',
    33                         'properties' => [
    34                                 'id' => [
    35                                         'type' => 'integer',
    36                                 ],
    37                                 'email' => [
    38                                         'type'   => 'string',
    39                                         'format' => 'email',
    40                                 ],
    41                                 'first_name' => [
    42                                         'type' => 'string',
    43                                 ],
    44                                 'last_name' => [
    45                                         'type' => 'string',
    46                                 ],
    47                                 'date_created' => [
    48                                         'type'        => 'string',
    49                                         'format'      => 'date-time',
    50                                         'description' => __( 'When the contact was created.', 'groundhogg' ),
    51                                 ],
    52                                 'optin_status' => [
    53                                         'type'        => 'object',
    54                                         'description' => __( 'Whether/how the contact can be marketed to.', 'groundhogg' ),
    55                                         'properties'  => [
    56                                                 'value' => [
    57                                                         'type'        => 'integer',
    58                                                         'description' => __( 'The raw Groundhogg\Preferences status constant.', 'groundhogg' ),
    59                                                 ],
    60                                                 'label' => [
    61                                                         'type' => 'string',
    62                                                 ],
    63                                         ],
    64                                 ],
    65                                 'tags' => [
    66                                         'type'        => 'array',
    67                                         'description' => __( 'Only present when "tags" is passed in include.', 'groundhogg' ),
    68                                         'items'       => Tag_Schema::get_schema(),
    69                                 ],
    70                                 'meta' => [
    71                                         'type'                 => 'object',
    72                                         'description'          => __( 'Only present when "meta" is passed in include. Raw meta key => value pairs, including custom fields. See groundhogg/list-custom-fields to map keys to labels.', 'groundhogg' ),
    73                                         'additionalProperties' => true,
    74                                 ],
    75                         ],
     111                        'properties' => self::extend_properties( $properties ),
    76112                ];
     113        }
     114
     115        /**
     116         * The valid values for groundhogg/get-contact's and groundhogg/search-contacts'
     117         * own `expand` input parameter (their `enum`, not just this class's $include).
     118         * extend()'d keys are included automatically - see the class docblock for
     119         * the escape hatch when an add-on isn't going through extend().
     120         *
     121         * @return string[]
     122         */
     123        public static function expand_options(): array {
     124                return self::extend_expand_options( [ 'tags', 'meta' ] );
    77125        }
    78126
    … …  
    82130         *
    83131         * @param Contact|int|string|object $object
    84          * @param array                     $include Optional sections to include: 'tags', 'meta'.
     132         * @param array                     $include Optional sections to include: 'tags', 'meta',
     133         *                                           plus anything an add-on has registered via
     134         *                                           Contact_Schema::extend() (see the class docblock).
    85135         *
    86136         * @return array
    … …  
    124174                }
    125175
    126                 return $data;
     176                return self::extend_transform( $data, $object, $include );
    127177        }
    128178
  • groundhogg/tags/4.8.1/includes/abilities/schemas/email-schema.php

    r3695315 r3695885  
    5555                                        'type'        => 'string',
    5656                                        'enum'        => [ 'html', 'blocks', 'legacy_blocks', 'legacy_plain' ],
    57                                         'description' => __( 'Which editor this email\'s content was built with, and which one wp-admin will open it in. "html": raw HTML (from the meta "type" flag, or content starting with "<!DOCTYPE"). "blocks": the current block editor (from the meta "blocks" flag) - content is HTML annotated with block comments ("<!--text:uuid {...}-->...<!--/text:uuid-->", etc.) that get parsed back into editable blocks. "legacy_blocks"/"legacy_plain": older formats inferred from the content itself. groundhogg/create-email\'s `editor` input controls which of "html"/"blocks" it produces.', 'groundhogg' ),
     57                                        'description' => __( 'Which editor this email\'s content was built with, and which one wp-admin will open it in. "html": raw HTML (from the meta "type" flag, or content starting with "<!DOCTYPE"). "blocks": the current block editor (from the meta "blocks" flag) - content is HTML annotated with block comments ("<!--text:uuid {...}-->...<!--/text:uuid-->", etc.) that get parsed back into editable blocks. "legacy_blocks"/"legacy_plain": older formats inferred from the content itself. groundhogg/create-email-template\'s `editor` input controls which of "html"/"blocks" it produces.', 'groundhogg' ),
    5858                                ],
    5959                                'template_settings' => [
    6060                                        'type'        => 'object',
    61                                         'description' => __( 'The outer page layout/background this email renders inside of - distinct from the block/HTML content itself. Mirrors the "Template Settings" panel in wp-admin\'s email editor. Set via groundhogg/create-email\'s or groundhogg/update-email\'s `template_settings` input.', 'groundhogg' ),
     61                                        'description' => __( 'The outer page layout/background this email renders inside of - distinct from the block/HTML content itself. Mirrors the "Template Settings" panel in wp-admin\'s email editor. Set via groundhogg/create-email-template\'s or groundhogg/update-email-template\'s `template_settings` input.', 'groundhogg' ),
    6262                                        'properties'  => self::template_settings_properties(),
    6363                                ],
    … …  
    100100         * The `template_settings` object's properties - shared by the output
    101101         * schema above and the `template_settings` input on
    102          * groundhogg/create-email and groundhogg/update-email, so the property
    103          * names, enums, and descriptions can't drift out of sync between them.
     102         * groundhogg/create-email-template and groundhogg/update-email-template, so
     103         * the property names, enums, and descriptions can't drift out of sync
     104         * between them.
    104105         *
    105106         * @param bool $with_defaults Include `default` values for "layout" and
    106          *                            "direction" - appropriate for create-email,
     107         *                            "direction" - appropriate for create-email-template,
    107108         *                            where a brand-new email needs a sensible
    108109         *                            starting point. Leave false for the output
    109          *                            schema and for update-email, where an absent
    110          *                            property should be left untouched rather
     110         *                            schema and for update-email-template, where an
     111         *                            absent property should be left untouched rather
    111112         *                            than implying it would reset to a default.
    112113         *
  • groundhogg/tags/4.8.1/includes/abilities/schemas/segment-schema.php

    r3695315 r3695885  
    44
    55use Groundhogg\Abilities\Traits\Has_Optin_Status;
     6use Groundhogg\Contact_Query;
    67use WP_Error;
    78use function Groundhogg\parse_tag_list;
    … …  
    2526 * describes *input*, not an output shape, so it has no get_schema()/transform().
    2627 *
    27  *   // in get_args():
    28  *   'properties' => array_merge( Segment_Schema::properties(), [ ...ability-specific... ] ),
    29  *
    30  *   // in __invoke():
     28 * Three ways to turn a validated $input into something queryable, depending on
     29 * what the calling ability actually does with it:
     30 *
     31 *   // Executes immediately (groundhogg/search-contacts): a live Contact_Query,
     32 *   // so the caller can keep chaining (setLimit(), setOrderby(), ...) and an
     33 *   // add-on can manipulate the query object directly - see
     34 *   // groundhogg/segment_schema/contact_query below.
     35 *   $contact_query = Segment_Schema::to_contact_query( $input );
     36 *   if ( is_wp_error( $contact_query ) ) { return $contact_query; }
     37 *   $results = $contact_query->setLimit( $limit )->setFoundRows( true )->query();
     38 *
     39 *   // Stored and (re-)run later (groundhogg/send-email-broadcast's dynamic
     40 *   // segments, groundhogg/add-to-flow's background batches): a plain,
     41 *   // serializable array of query vars - a live query object (and whatever a
     42 *   // filter attached to it via addJoin()/where()) can't survive that round
     43 *   // trip.
    3144 *   $query = Segment_Schema::to_query( $input );
    3245 *   if ( is_wp_error( $query ) ) { return $query; }
    33  *   // ... hand $query to Contact_Query / Broadcast::schedule() / ...
     46 *   // ... hand $query to Broadcast::schedule() / Background_Tasks / ...
     47 *
     48 *   // A branch/logic step's own condition setting (e.g. if_else's
     49 *   // `include_filters`) - Groundhogg's Filters DSL directly, not a
     50 *   // Contact_Query-vars array at all.
     51 *   $filters = Segment_Schema::to_filters( $input );
     52 *   if ( is_wp_error( $filters ) ) { return $filters; }
     53 *   // ... use as an if_else/branch-logic step's include_filters setting
     54 *
     55 *   // in get_args(), for all three:
     56 *   'properties' => array_merge( Segment_Schema::properties(), [ ...ability-specific... ] ),
     57 *
     58 * Extensible, so an add-on can add its own audience-filtering params to every
     59 * ability built on this schema (groundhogg/search-contacts,
     60 * groundhogg/send-email-broadcast, groundhogg/add-to-flow, ...) without modifying
     61 * this class or any of them individually.
     62 *
     63 * The easy way in is Segment_Schema::extend() - one call registers a new input
     64 * property (its schema) and a callback that turns it into Groundhogg's Filters
     65 * DSL (the same condition format Contact_Query's own `include_filters` query var
     66 * and branch-logic step settings like if_else's `include_filters` consume - see
     67 * `Groundhogg\DB\Query\Filters` and Contact_Query's `filter_*` methods for the
     68 * full vocabulary of condition `type`s). Returning filter conditions rather than
     69 * mutating a query-vars array directly means one extend() call works for BOTH
     70 * to_query()/to_contact_query() (folded into `include_filters`) AND to_filters()
     71 * (used for branch/logic conditions) - not two things to hook separately:
     72 *
     73 *     Segment_Schema::extend(
     74 *         'woocommerce',
     75 *         [
     76 *             'type'                 => 'object',
     77 *             'additionalProperties' => false,
     78 *             'properties'           => [
     79 *                 'min_orders' => [
     80 *                     'type'        => 'integer',
     81 *                     'description' => __( 'Only contacts with at least this many WooCommerce orders.', 'my-plugin' ),
     82 *                 ],
     83 *             ],
     84 *         ],
     85 *         function ( array $input ): array {
     86 *             if ( empty( $input['woocommerce']['min_orders'] ) ) {
     87 *                 return [];
     88 *             }
     89 *             // Requires a filter type actually registered with Contact_Query's
     90 *             // Filters instance (via groundhogg/contact_query/filters/register) -
     91 *             // extend() only carries conditions through, it doesn't invent new
     92 *             // filter types on its own.
     93 *             return [ [
     94 *                 'type'    => 'woocommerce_orders',
     95 *                 'compare' => 'greater_than_or_equal_to',
     96 *                 'value'   => absint( $input['woocommerce']['min_orders'] ),
     97 *             ] ];
     98 *         },
     99 *         true // this alone counts as naming a deliberate audience - see has_audience()
     100 *     );
     101 *
     102 * Call this once (e.g. on `init`, after confirming both Groundhogg and the
     103 * dependency it needs are active) - before any ability builds its input schema.
     104 *
     105 * For query logic no Filters condition type can express at all (a join, a raw
     106 * where condition, a custom select), reach for to_contact_query()'s live
     107 * Contact_Query object instead, via the `groundhogg/segment_schema/contact_query`
     108 * action below - only usable by abilities that execute the query themselves
     109 * rather than storing it (see to_contact_query()'s own docblock).
     110 *
     111 * Everything extend() does is built on filters, also available directly for
     112 * anything extend() can't express (e.g. contributing to more than one property
     113 * from a single registration):
     114 *
     115 * - `groundhogg/segment_schema/properties` filters the input properties (properties(),
     116 *   below).
     117 * - `groundhogg/segment_schema/query` filters the plain Contact_Query-vars array
     118 *   built from $input (to_query(), below) - runs after extend()'d conditions are
     119 *   already folded into `include_filters`.
     120 * - `groundhogg/segment_schema/filters` filters the Filters-DSL array built from
     121 *   $input (to_filters(), below) - same timing, after extend()'d conditions are
     122 *   already merged in.
     123 * - `groundhogg/segment_schema/audience_keys` adds to the keys has_audience() treats
     124 *   as deliberately naming a group of contacts, so an ability that must not act on
     125 *   the whole database by accident still recognizes a request that only supplies
     126 *   an add-on's own param as targeted, not "everyone".
    34127 */
    35128class Segment_Schema {
    … …  
    57150
    58151        /**
    59          * The input_schema `properties` for a contact segment. Every condition is
    60          * ANDed together. Spread these into an ability's own `properties`.
     152         * Add-on-registered input properties, keyed by property name. Populated by
     153         * extend().
     154         *
     155         * @var array<string, array{schema: array, callback: callable, audience: bool}>
     156         */
     157        private static array $extensions = [];
     158
     159        /**
     160         * Register an additional audience-filtering input property - see the class
     161         * docblock for the full picture. One call adds the property to the input
     162         * schema (properties(), below) and wires the callback that turns it into
     163         * Filters-DSL conditions, merged into `include_filters` for to_query()/
     164         * to_contact_query() and into the result of to_filters() alike - instead of
     165         * hooking groundhogg/segment_schema/properties and (one or both of)
     166         * groundhogg/segment_schema/query / groundhogg/segment_schema/filters by hand.
     167         *
     168         * @param string   $key             The property name, e.g. "woocommerce". Must
     169         *                                  not collide with a built-in name or an
     170         *                                  existing extend()'d one - either is refused
     171         *                                  with _doing_it_wrong() rather than silently
     172         *                                  overwriting something else already relying
     173         *                                  on that key.
     174         * @param array    $schema          The full JSON schema fragment for this
     175         *                                  property (type, description, enum,
     176         *                                  properties, items, ...), used as-is. Unlike
     177         *                                  Contact_Schema::extend()'s simpler
     178         *                                  description+type shorthand, a segment
     179         *                                  filter's shape varies too much (an object
     180         *                                  with its own sub-properties, an enum, a
     181         *                                  plain scalar, ...) to usefully paper over.
     182         * @param callable $callback        function( array $input ): array|WP_Error.
     183         *                                  Receives the full $input and returns a flat
     184         *                                  list of Filters-DSL condition arrays (each
     185         *                                  `['type' => ..., ...]`) contributed by this
     186         *                                  property - empty array if $key isn't present
     187         *                                  in $input - or a WP_Error to reject the
     188         *                                  input outright, same as to_query()'s own
     189         *                                  tags_include handling. The `type` used must
     190         *                                  be a filter type actually registered with
     191         *                                  Contact_Query's Filters instance (see the
     192         *                                  class docblock) - extend() carries
     193         *                                  conditions through, it doesn't register new
     194         *                                  filter types itself. Called on every
     195         *                                  to_query()/to_contact_query()/to_filters(),
     196         *                                  regardless of whether $key is actually
     197         *                                  present - check $input[ $key ] yourself.
     198         * @param bool     $is_audience_key Whether supplying this property alone (with
     199         *                                  no other segment param) should count as
     200         *                                  deliberately naming a group of contacts -
     201         *                                  see has_audience(). Default false.
     202         *
     203         * @return void
     204         */
     205        public static function extend( string $key, array $schema, callable $callback, bool $is_audience_key = false ) {
     206
     207                if ( array_key_exists( $key, self::base_properties() ) || isset( self::$extensions[ $key ] ) ) {
     208                        _doing_it_wrong(
     209                                __METHOD__,
     210                                sprintf( 'A Segment_Schema property named "%s" already exists.', $key ),
     211                                '4.8'
     212                        );
     213
     214                        return;
     215                }
     216
     217                self::$extensions[ $key ] = [
     218                        'schema'   => $schema,
     219                        'callback' => $callback,
     220                        'audience' => $is_audience_key,
     221                ];
     222        }
     223
     224        /**
     225         * This schema's own built-in input properties, before add-on extensions or
     226         * the groundhogg/segment_schema/properties filter. Split out from
     227         * properties() so extend() can check a proposed key against it without
     228         * re-running the filter (and without properties() needing to special-case
     229         * its own extensions when checking for collisions).
    61230         *
    62231         * @return array
    63232         */
    64         public static function properties(): array {
     233        private static function base_properties(): array {
     234
    65235                return [
    66236                        'search' => [
    … …  
    179349
    180350        /**
     351         * The input_schema `properties` for a contact segment. Every condition is
     352         * ANDed together. Spread these into an ability's own `properties`.
     353         *
     354         * @return array
     355         */
     356        public static function properties(): array {
     357
     358                $properties = self::base_properties();
     359
     360                foreach ( self::$extensions as $key => $extension ) {
     361                        $properties[ $key ] = $extension['schema'];
     362                }
     363
     364                // See the class docblock for how an add-on adds its own properties here.
     365                return apply_filters( 'groundhogg/segment_schema/properties', $properties );
     366        }
     367
     368        /**
    181369         * Whether $input names a deliberate group of contacts (vs. "everyone" or just
    182370         * refinements). An ability that must not act on the whole database by
    … …  
    189377        public static function has_audience( array $input ): bool {
    190378
    191                 foreach ( self::AUDIENCE_KEYS as $key ) {
     379                $audience_keys = self::AUDIENCE_KEYS;
     380
     381                foreach ( self::$extensions as $key => $extension ) {
     382                        if ( $extension['audience'] ) {
     383                                $audience_keys[] = $key;
     384                        }
     385                }
     386
     387                // See the class docblock for how an add-on adds its own key here.
     388                $audience_keys = apply_filters( 'groundhogg/segment_schema/audience_keys', $audience_keys );
     389
     390                foreach ( $audience_keys as $key ) {
    192391                        if ( ! empty( $input[ $key ] ) ) {
    193392                                return true;
    … …  
    349548                }
    350549
    351                 return $query;
     550                $extension_conditions = self::extension_filter_conditions( $input );
     551
     552                if ( is_wp_error( $extension_conditions ) ) {
     553                        return $extension_conditions;
     554                }
     555
     556                if ( ! empty( $extension_conditions ) ) {
     557                        // Merged into the same single AND-group `include_filters` already uses
     558                        // elsewhere in this method (see to_filters() for the built-in fields'
     559                        // equivalent) - Contact_Query natively understands `include_filters`
     560                        // alongside the native query vars above (db.php's `case 'include_filters'`),
     561                        // so this doesn't conflict with anything already in $query.
     562                        $query['include_filters'][0] = array_merge(
     563                                $query['include_filters'][0] ?? [],
     564                                $extension_conditions
     565                        );
     566                }
     567
     568                // See the class docblock for how an add-on translates its own input
     569                // properties into query vars here. Only reached on success - the early
     570                // WP_Error returns above (e.g. unknown tags, an extend()'d callback
     571                // rejecting its own input) bypass this filter, same as any other hard
     572                // validation failure.
     573                return apply_filters( 'groundhogg/segment_schema/query', $query, $input );
     574        }
     575
     576        /**
     577         * Run every extend()'d property's callback against $input and collect the
     578         * Filters-DSL conditions they contribute, for merging into either
     579         * to_query()'s `include_filters` or to_filters()'s result.
     580         *
     581         * @param array $input
     582         *
     583         * @return array|WP_Error A flat list of condition arrays (possibly empty), or
     584         *                        the first WP_Error a callback returns.
     585         */
     586        private static function extension_filter_conditions( array $input ) {
     587
     588                $conditions = [];
     589
     590                foreach ( self::$extensions as $key => $extension ) {
     591
     592                        $result = call_user_func( $extension['callback'], $input );
     593
     594                        if ( is_wp_error( $result ) ) {
     595                                return $result;
     596                        }
     597
     598                        if ( ! empty( $result ) ) {
     599                                array_push( $conditions, ...array_values( $result ) );
     600                        }
     601                }
     602
     603                return $conditions;
     604        }
     605
     606        /**
     607         * Maps a `meta` condition's `compare` (this class's own vocabulary, matching
     608         * Where::compare() - see META_COMPARISONS) to the equivalent
     609         * Groundhogg\DB\Query\Filters::string() compare keyword, for to_filters().
     610         * The two vocabularies grew independently and mostly agree, except:
     611         * "in"/"not_in" here must become "any_of"/"none_of" for Filters::string() -
     612         * passed through unmapped, Filters::string() silently compares against only
     613         * the first array value instead of the whole list, since it only special-cases
     614         * exactly "any_of"/"none_of" before falling back to a scalar comparison.
     615         * "like"/"not_like" have no exact Filters::string() equivalent and are mapped
     616         * to the closest substring match ("contains"/"not_contains").
     617         */
     618        private const META_COMPARE_TO_FILTER_COMPARE = [
     619                'equals'                   => 'equals',
     620                'not_equals'               => 'not_equals',
     621                'less_than'                => 'less_than',
     622                'greater_than'             => 'greater_than',
     623                'less_than_or_equal_to'    => 'less_than_or_equal_to',
     624                'greater_than_or_equal_to' => 'greater_than_or_equal_to',
     625                'in'                       => 'any_of',
     626                'not_in'                   => 'none_of',
     627                'like'                     => 'contains',
     628                'not_like'                 => 'not_contains',
     629                'contains'                 => 'contains',
     630                'not_contains'             => 'not_contains',
     631                'starts_with'              => 'starts_with',
     632                'ends_with'                => 'ends_with',
     633                'empty'                    => 'empty',
     634                'not_empty'                => 'not_empty',
     635        ];
     636
     637        /**
     638         * Build Groundhogg's Filters DSL (Groundhogg\DB\Query\Filters::sanitize()'s
     639         * expected shape - an OR-of-AND condition list, e.g.
     640         * `[[ {type:'tags', ...}, {type:'owner', ...} ]]`) from a validated segment
     641         * $input. This is the format branch/logic step settings consume directly
     642         * (e.g. if_else's `include_filters`/`exclude_filters`) - unlike to_query()/
     643         * to_contact_query(), the result here is NOT a Contact_Query-vars array, and
     644         * running it requires a Contact_Query (or a step type) that understands the
     645         * Filters DSL, not a raw query.
     646         *
     647         * Every translated condition lands in a single AND-group (`[[ ...all of
     648         * them... ]]`), matching Segment_Schema's own "every condition ANDed
     649         * together" semantics - to_query()'s notion of ANDing everything, expressed
     650         * here as one AND-group with no OR alternatives.
     651         *
     652         * `meta_relation: OR` is expressed via the `sub_query` filter type
     653         * (Contact_Query::filter_sub_query()), which takes its own nested
     654         * `include_filters` (a full OR-of-AND array) and applies it inside a
     655         * sub-Where group - exactly the primitive needed to nest an OR *inside* the
     656         * single outer AND-group everything else lands in, without restructuring the
     657         * whole condition list or duplicating every other condition into each OR
     658         * branch. See the `meta` handling below.
     659         *
     660         * One known gap: `search` has no Filters equivalent (Contact_Query's own
     661         * `search` handling is a query-var-level heuristic - email vs. full name -
     662         * not a Filters condition) and is silently ignored here. Use
     663         * to_query()/to_contact_query() if `search` needs to actually apply.
     664         *
     665         * @param array $input
     666         *
     667         * @return array|WP_Error
     668         */
     669        public static function to_filters( array $input ) {
     670
     671                $conditions = [];
     672
     673                if ( ! empty( $input['include'] ) ) {
     674                        $conditions[] = [
     675                                'type'    => 'contact_id',
     676                                'compare' => 'any_of',
     677                                'value'   => wp_parse_id_list( $input['include'] ),
     678                        ];
     679                }
     680
     681                if ( ! empty( $input['exclude'] ) ) {
     682                        $conditions[] = [
     683                                'type'    => 'contact_id',
     684                                'compare' => 'none_of',
     685                                'value'   => wp_parse_id_list( $input['exclude'] ),
     686                        ];
     687                }
     688
     689                if ( ! empty( $input['tags_include'] ) ) {
     690
     691                        // $create = false: resolving an audience must never mint a tag.
     692                        $resolved = parse_tag_list( $input['tags_include'], 'ID', false );
     693
     694                        if ( empty( $resolved ) ) {
     695                                return new WP_Error(
     696                                        'groundhogg_unknown_tags',
     697                                        __( 'None of the tags in tags_include match an existing tag.', 'groundhogg' )
     698                                );
     699                        }
     700
     701                        $conditions[] = [
     702                                'type'     => 'tags',
     703                                'tags'     => $resolved,
     704                                'compare'  => 'includes',
     705                                'compare2' => ! empty( $input['tags_include_needs_all'] ) ? 'all' : 'any',
     706                        ];
     707                }
     708
     709                if ( ! empty( $input['tags_exclude'] ) ) {
     710
     711                        $resolved = parse_tag_list( $input['tags_exclude'], 'ID', false );
     712
     713                        if ( ! empty( $resolved ) ) {
     714                                $conditions[] = [
     715                                        'type'     => 'tags',
     716                                        'tags'     => $resolved,
     717                                        'compare'  => 'excludes',
     718                                        'compare2' => ! empty( $input['tags_exclude_needs_all'] ) ? 'all' : 'any',
     719                                ];
     720                        }
     721                }
     722
     723                if ( ! empty( $input['optin_status'] ) && is_array( $input['optin_status'] ) ) {
     724                        $conditions[] = [
     725                                'type'    => 'optin_status',
     726                                'value'   => self::resolve_optin_status_list( $input['optin_status'] ),
     727                                'compare' => 'in',
     728                        ];
     729                }
     730
     731                if ( ! empty( $input['optin_status_exclude'] ) && is_array( $input['optin_status_exclude'] ) ) {
     732                        $conditions[] = [
     733                                'type'    => 'optin_status',
     734                                'value'   => self::resolve_optin_status_list( $input['optin_status_exclude'] ),
     735                                'compare' => 'not_in',
     736                        ];
     737                }
     738
     739                if ( isset( $input['marketable'] ) ) {
     740                        $conditions[] = [
     741                                'type'       => 'is_marketable',
     742                                'marketable' => $input['marketable'] ? 'yes' : 'no',
     743                        ];
     744                }
     745
     746                if ( ! empty( $input['owner'] ) ) {
     747                        $conditions[] = [
     748                                'type'    => 'owner',
     749                                'value'   => wp_parse_id_list( $input['owner'] ),
     750                                'compare' => 'in',
     751                        ];
     752                }
     753
     754                if ( ! empty( $input['users_include'] ) ) {
     755                        $conditions[] = [
     756                                'type'    => 'user_id',
     757                                'value'   => wp_parse_id_list( $input['users_include'] ),
     758                                'compare' => 'any_of',
     759                        ];
     760                }
     761
     762                if ( ! empty( $input['users_exclude'] ) ) {
     763                        $conditions[] = [
     764                                'type'    => 'user_id',
     765                                'value'   => wp_parse_id_list( $input['users_exclude'] ),
     766                                'compare' => 'none_of',
     767                        ];
     768                }
     769
     770                if ( isset( $input['has_user'] ) ) {
     771                        if ( $input['has_user'] ) {
     772                                $conditions[] = [ 'type' => 'is_user' ];
     773                        } else {
     774                                // No negated "is_user" filter type exists - approximate "no linked
     775                                // user" as user_id being exactly the unset value.
     776                                $conditions[] = [ 'type' => 'user_id', 'compare' => 'equals', 'value' => 0 ];
     777                        }
     778                }
     779
     780                if ( ! empty( $input['created_after'] ) && ! empty( $input['created_before'] ) ) {
     781                        $conditions[] = [
     782                                'type'       => 'date_created',
     783                                'date_range' => 'between',
     784                                'after'      => sanitize_text_field( $input['created_after'] ),
     785                                'before'     => sanitize_text_field( $input['created_before'] ),
     786                        ];
     787                } else if ( ! empty( $input['created_after'] ) ) {
     788                        $conditions[] = [
     789                                'type'       => 'date_created',
     790                                'date_range' => 'after',
     791                                'after'      => sanitize_text_field( $input['created_after'] ),
     792                        ];
     793                } else if ( ! empty( $input['created_before'] ) ) {
     794                        $conditions[] = [
     795                                'type'       => 'date_created',
     796                                'date_range' => 'before',
     797                                'before'     => sanitize_text_field( $input['created_before'] ),
     798                        ];
     799                }
     800
     801                if ( ! empty( $input['saved_search'] ) ) {
     802                        $conditions[] = [
     803                                'type'    => 'saved_search',
     804                                'search'  => sanitize_text_field( $input['saved_search'] ),
     805                                'compare' => 'in',
     806                        ];
     807                }
     808
     809                if ( ! empty( $input['meta'] ) && is_array( $input['meta'] ) ) {
     810
     811                        $meta_conditions = [];
     812
     813                        foreach ( $input['meta'] as $condition ) {
     814
     815                                $condition = (array) $condition;
     816                                $key       = isset( $condition['key'] ) ? sanitize_key( $condition['key'] ) : '';
     817
     818                                if ( ! $key ) {
     819                                        continue;
     820                                }
     821
     822                                $compare = in_array( $condition['compare'] ?? 'equals', self::META_COMPARISONS, true )
     823                                        ? $condition['compare']
     824                                        : 'equals';
     825
     826                                $meta_conditions[] = [
     827                                        'type'    => 'meta',
     828                                        'meta'    => $key,
     829                                        'compare' => self::META_COMPARE_TO_FILTER_COMPARE[ $compare ] ?? 'equals',
     830                                        'value'   => $condition['value'] ?? '',
     831                                ];
     832                        }
     833
     834                        $is_or = ! empty( $input['meta_relation'] ) && strtoupper( $input['meta_relation'] ) === 'OR';
     835
     836                        if ( $is_or && count( $meta_conditions ) > 1 ) {
     837                                // Nest as one OR-of-AND sub-group inside the outer AND-group, via
     838                                // the sub_query filter type - see the class docblock for why this
     839                                // is the only way to express an OR here without restructuring
     840                                // everything else. Each meta condition becomes its own single-item
     841                                // AND-group, ORed together.
     842                                $conditions[] = [
     843                                        'type'            => 'sub_query',
     844                                        'include_filters' => array_map( static fn( $condition ) => [ $condition ], $meta_conditions ),
     845                                ];
     846                        } else {
     847                                // Either explicitly ANDed, or only one condition (where AND/OR are
     848                                // equivalent) - no need for the sub_query wrapper, just AND them
     849                                // into the outer group directly like everything else.
     850                                array_push( $conditions, ...$meta_conditions );
     851                        }
     852                }
     853
     854                $extension_conditions = self::extension_filter_conditions( $input );
     855
     856                if ( is_wp_error( $extension_conditions ) ) {
     857                        return $extension_conditions;
     858                }
     859
     860                array_push( $conditions, ...$extension_conditions );
     861
     862                // See the class docblock for how an add-on adds its own conditions here.
     863                return apply_filters( 'groundhogg/segment_schema/filters', empty( $conditions ) ? [] : [ $conditions ], $input );
     864        }
     865
     866        /**
     867         * Build a live Contact_Query from a validated segment $input - same
     868         * conditions as to_query(), but as a query object the caller can keep
     869         * building on (setLimit(), setOrderby(), ...) and an add-on can manipulate
     870         * directly via `groundhogg/segment_schema/contact_query` for anything a
     871         * plain query-var array can't express (a join, a raw where condition).
     872         * See the class docblock for when to reach for this vs. to_query().
     873         *
     874         * Does not run the query - the caller decides when (query(), get_results(),
     875         * count(), ...) and with what pagination/ordering on top.
     876         *
     877         * @param array $input
     878         * @param int   $flags Passed through to Contact_Query's constructor (e.g.
     879         *                     Contact_Query::IS_SUB_QUERY).
     880         *
     881         * @return Contact_Query|WP_Error
     882         */
     883        public static function to_contact_query( array $input, int $flags = 0 ) {
     884
     885                $query_vars = self::to_query( $input );
     886
     887                if ( is_wp_error( $query_vars ) ) {
     888                        return $query_vars;
     889                }
     890
     891                $contact_query = new Contact_Query( $query_vars, $flags );
     892
     893                /**
     894                 * Fires with the live Contact_Query built from a segment $input, so an
     895                 * add-on can manipulate it directly - see the class docblock's
     896                 * WooCommerce example. The query hasn't run yet, so anything applied
     897                 * here (addJoin(), where(), setSelect(), ...) takes effect normally.
     898                 *
     899                 * @param Contact_Query $contact_query
     900                 * @param array         $input
     901                 */
     902                do_action( 'groundhogg/segment_schema/contact_query', $contact_query, $input );
     903
     904                return $contact_query;
    352905        }
    353906
  • groundhogg/tags/4.8.1/includes/abilities/tags/list-tags.php

    r3695315 r3695885  
    1414class List_Tags extends Ability {
    1515
    16         protected const string NAME       = 'groundhogg/list-tags';
    17         protected const string CATEGORY   = 'groundhogg-tags';
    18         protected const string CAPABILITY = 'manage_tags';
     16        protected const NAME       = 'groundhogg/list-tags';
     17        protected const CATEGORY   = 'groundhogg-tags';
     18        protected const CAPABILITY = 'manage_tags';
    1919
    20         protected const bool READONLY   = true;
    21         protected const bool IDEMPOTENT = true;
     20        protected const READONLY   = true;
     21        protected const IDEMPOTENT = true;
    2222
    2323        protected function get_args(): array {
  • groundhogg/tags/4.8.1/includes/abilities/utils/upload-media.php

    r3695315 r3695885  
    99 * Uploads a file into the WordPress Media Library and returns its ID, URL,
    1010 * and relative path - the missing piece for building an email (via
    11  * groundhogg/create-email or groundhogg/update-email) that includes a real
    12  * image rather than a placeholder: upload the image first, then use the
    13  * returned `url` as an `image` block's `src` (see the groundhogg-block-email
    14  * skill) or a plain `<img>` src in HTML content.
     11 * groundhogg/create-email-template or groundhogg/update-email-template) that
     12 * includes a real image rather than a placeholder: upload the image first,
     13 * then use the returned `url` as an `image` block's `src` (see the
     14 * groundhogg-block-email skill) or a plain `<img>` src in HTML content.
    1515 *
    1616 * `data` is base64, not a raw file upload - abilities are called with a JSON
    … …  
    3737class Upload_Media extends Ability {
    3838
    39         protected const string NAME       = 'groundhogg/upload-media';
    40         protected const string CATEGORY   = 'groundhogg-utils';
    41         protected const string CAPABILITY = 'upload_files';
     39        protected const NAME       = 'groundhogg/upload-media';
     40        protected const CATEGORY   = 'groundhogg-utils';
     41        protected const CAPABILITY = 'upload_files';
    4242
    43         protected const bool READONLY    = false;
    44         protected const bool DESTRUCTIVE = false;
    45         protected const bool IDEMPOTENT  = false;
     43        protected const READONLY    = false;
     44        protected const DESTRUCTIVE = false;
     45        protected const IDEMPOTENT  = false;
    4646
    4747        protected function get_args(): array {
    … …  
    4949                return [
    5050                        'label'       => __( 'Upload Media', 'groundhogg' ),
    51                         'description' => __( 'Upload a file (e.g. an image) into the WordPress Media Library from base64 data. Returns the attachment\'s id, URL, and relative path, for use as an image src elsewhere - e.g. an image block in groundhogg/create-email\'s content.', 'groundhogg' ),
     51                        'description' => __( 'Upload a file (e.g. an image) into the WordPress Media Library from base64 data. Returns the attachment\'s id, URL, and relative path, for use as an image src elsewhere - e.g. an image block in groundhogg/create-email-template\'s content.', 'groundhogg' ),
    5252
    5353                        'input_schema' => [
  • groundhogg/trunk/README.txt

    r3695315 r3695885  
    77Tested up to: 7.1
    88Requires PHP: 7.4
    9 Stable tag: 4.8
     9Stable tag: 4.8.1
    1010License: GPLv3
    1111License URI: https://www.gnu.org/licenses/gpl.md
    … …  
    391391
    392392== Changelog ==
     393
     394= 4.8.1 (2026-09-14) =
     395* ADDED "Create Flow" and "Activate Flow" abilities, so an AI agent can build a flow (funnel) from scratch, including branching steps, and turn it on. A new "List Step Types" ability lists the buildable step types and their settings so an agent can discover what's available before building.
     396* ADDED "List Replacement Codes" ability for discovering the merge tags available for use in emails.
     397* ADDED Add-ons can now register their own ability categories and abilities alongside Groundhogg's, and extend the contact and segment schemas with their own fields, so the WordPress Abilities API integration introduced in 4.8 isn't limited to what Groundhogg itself provides.
     398* TWEAKED Email-related abilities are now named consistently around "email template" (`create-email-template`, `update-email-template`, `get-email-template`) instead of a mix of "email" and "email-template".
     399* TWEAKED Ability descriptions now link out to relevant documentation for easier reference when working with an agent.
    393400
    394401= 4.8 (2026-09-10) =
  • groundhogg/trunk/groundhogg.php

    r3695315 r3695885  
    44 * Plugin URI: https://groundhogg.io/?utm_source=wp-plugins&utm_campaign=plugin-uri&utm_medium=wp-dash
    55 * Description: CRM and marketing automation for WordPress
    6  * Version: 4.8
     6 * Version: 4.8.1
    77 * Author: Groundhogg Inc.
    88 * Author URI: https://groundhogg.io/?utm_source=wp-plugins&utm_campaign=author-uri&utm_medium=wp-dash
    … …  
    2727if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly
    2828
    29 define( 'GROUNDHOGG_VERSION', '4.8' );
    30 define( 'GROUNDHOGG_PREVIOUS_STABLE_VERSION', '4.7.2' );
     29define( 'GROUNDHOGG_VERSION', '4.8.1' );
     30define( 'GROUNDHOGG_PREVIOUS_STABLE_VERSION', '4.8' );
    3131
    3232define( 'GROUNDHOGG__FILE__', __FILE__ );
  • groundhogg/trunk/includes/abilities/abilities.php

    r3695315 r3695885  
    1919use Groundhogg\Abilities\Db\Describe_Table;
    2020use Groundhogg\Abilities\Db\Query_Table;
     21use Groundhogg\Abilities\Funnels\Activate_Flow;
    2122use Groundhogg\Abilities\Funnels\Add_To_Flow;
     23use Groundhogg\Abilities\Funnels\Create_Flow;
     24use Groundhogg\Abilities\Funnels\Deactivate_Flow;
    2225use Groundhogg\Abilities\Funnels\List_Flows;
    23 use Groundhogg\Abilities\Emails\Create_Email;
     26use Groundhogg\Abilities\Funnels\List_Step_Types;
     27use Groundhogg\Abilities\Emails\Create_Email_Template;
     28use Groundhogg\Abilities\Emails\Get_Email_Template;
    2429use Groundhogg\Abilities\Emails\List_Email_Templates;
     30use Groundhogg\Abilities\Emails\List_Replacement_Codes;
    2531use Groundhogg\Abilities\Emails\List_Sender_Profiles;
    2632use Groundhogg\Abilities\Emails\Send_Composed_Email;
    2733use Groundhogg\Abilities\Emails\Send_Email_Template;
    28 use Groundhogg\Abilities\Emails\Update_Email;
     34use Groundhogg\Abilities\Emails\Update_Email_Template;
    2935use Groundhogg\Abilities\Reports\Get_Reports;
    3036use Groundhogg\Abilities\Reports\List_Report_Types;
    … …  
    3238use Groundhogg\Abilities\Utils\Upload_Media;
    3339
     40/**
     41 * Registers Groundhogg's own abilities/categories, and acts as the registry add-ons use to
     42 * register their own alongside them - Abilities::add_category() and Abilities::add_ability().
     43 *
     44 * Both must be called before the WP Abilities API actually registers anything - the
     45 * 'wp_abilities_api_categories_init'/'wp_abilities_api_init' hooks below, which normally fire on
     46 * 'init'. Groundhogg's own bootstrap runs on 'plugins_loaded' at priority 0 (see Plugin::init()),
     47 * so any add-on's own 'plugins_loaded' (default priority 10) or 'init' callback is early enough.
     48 * Registering from that far out just queues the category/class; if a call ever comes in after the
     49 * relevant hook has already fired, it's applied immediately instead of being silently dropped.
     50 *
     51 * Example - a 3rd-party add-on registering its own category and ability:
     52 *
     53 *     add_action( 'plugins_loaded', function () {
     54 *
     55 *         Abilities::add_category( 'my-addon', [
     56 *             'label'       => __( 'My Add-on', 'my-addon' ),
     57 *             'description' => __( 'Abilities provided by My Add-on.', 'my-addon' ),
     58 *         ] );
     59 *
     60 *         Abilities::add_ability( My_Addon\Abilities\Do_The_Thing::class );
     61 *     } );
     62 *
     63 * Do_The_Thing must extend Groundhogg\Abilities\Ability just like Groundhogg's own abilities -
     64 * it's instantiated the same way, which is what actually calls wp_register_ability().
     65 */
    3466class Abilities {
     67
     68        /**
     69         * Ability classes registered by add-ons via self::add_ability(), instantiated alongside
     70         * Groundhogg's own in register_abilities().
     71         *
     72         * @var string[]
     73         */
     74        protected static array $extra_abilities = [];
     75
     76        /**
     77         * Ability categories registered by add-ons via self::add_category(), registered alongside
     78         * Groundhogg's own in register_categories().
     79         *
     80         * @var array<string, array>
     81         */
     82        protected static array $extra_categories = [];
    3583
    3684        public function __construct() {
    … …  
    5199        }
    52100
     101        /**
     102         * Register an additional ability category, so a 3rd-party add-on's abilities can be grouped
     103         * under their own category instead of one of Groundhogg's. See the class docblock for a full
     104         * example and the timing requirement.
     105         *
     106         * @param string $slug the category slug, e.g. "my-addon"
     107         * @param array  $args ['label' => string, 'description' => string], same shape
     108         *                     wp_register_ability_category() itself takes
     109         *
     110         * @return void
     111         */
     112        public static function add_category( string $slug, array $args ) {
     113
     114                self::$extra_categories[ $slug ] = $args;
     115
     116                // The categories hook already fired - register it right away rather than dropping it.
     117                if ( did_action( 'wp_abilities_api_categories_init' ) && function_exists( 'wp_register_ability_category' ) ) {
     118                        wp_register_ability_category( $slug, $args );
     119                }
     120        }
     121
     122        /**
     123         * Register an additional ability class, instantiated the same way Groundhogg's own abilities
     124         * are - which is what actually calls wp_register_ability(). See the class docblock for a full
     125         * example and the timing requirement.
     126         *
     127         * @param string $class fully-qualified class name, extending Groundhogg\Abilities\Ability
     128         *
     129         * @return void
     130         */
     131        public static function add_ability( string $class ) {
     132
     133                self::$extra_abilities[] = $class;
     134
     135                // The abilities hook already fired - register it right away rather than dropping it.
     136                if ( did_action( 'wp_abilities_api_init' ) ) {
     137                        new $class();
     138                }
     139        }
     140
    53141        public function register_categories() {
    54142
    … …  
    97185                        'description' => __( 'General-purpose utilities that support the other categories but aren\'t specific to any one of them.', 'groundhogg' ),
    98186                ] );
     187
     188                foreach ( self::$extra_categories as $slug => $args ) {
     189                        wp_register_ability_category( $slug, $args );
     190                }
    99191        }
    100192
    101193        public function register_abilities() {
    102194
    103                 foreach ( [
     195                $abilities = array_merge( [
    104196                        Get_Contact::class,
    105197                        Create_Contact::class,
    … …  
    114206                        List_Contact_Notes::class,
    115207                        List_Email_Templates::class,
     208                        Get_Email_Template::class,
    116209                        List_Sender_Profiles::class,
    117                         Create_Email::class,
    118                         Update_Email::class,
     210                        Create_Email_Template::class,
     211                        Update_Email_Template::class,
     212                        List_Replacement_Codes::class,
    119213                        Send_Composed_Email::class,
    120214                        Send_Email_Template::class,
    … …  
    124218                        Cancel_Broadcast::class,
    125219                        List_Flows::class,
     220                        List_Step_Types::class,
     221                        Create_Flow::class,
     222                        Activate_Flow::class,
     223                        Deactivate_Flow::class,
    126224                        Add_To_Flow::class,
    127225                        List_Report_Types::class,
    … …  
    130228                        Query_Table::class,
    131229                        Upload_Media::class,
    132                 ] as $ability ){
    133 
    134                         $ability = new $ability();
    135 
    136                 }
    137 
     230                ], self::$extra_abilities );
     231
     232                foreach ( $abilities as $ability ) {
     233                        new $ability();
     234                }
    138235        }
    139236}
  • groundhogg/trunk/includes/abilities/ability.php

    r3695315 r3695885  
    55abstract class Ability {
    66
    7         protected const string NAME       = '';
    8         protected const string CATEGORY   = '';
    9         protected const string CAPABILITY = '';
     7        protected const NAME       = '';
     8        protected const CATEGORY   = '';
     9        protected const CAPABILITY = '';
    1010
    11         protected const bool PUBLIC      = true;
    12         protected const bool READONLY    = false;
    13         protected const bool DESTRUCTIVE = false;
    14         protected const bool IDEMPOTENT  = false;
     11        protected const PUBLIC      = true;
     12        protected const READONLY    = false;
     13        protected const DESTRUCTIVE = false;
     14        protected const IDEMPOTENT  = false;
    1515
    1616        /**
  • groundhogg/trunk/includes/abilities/broadcasts/cancel-broadcast.php

    r3695315 r3695885  
    2323class Cancel_Broadcast extends Ability {
    2424
    25         protected const string NAME       = 'groundhogg/cancel-broadcast';
    26         protected const string CATEGORY   = 'groundhogg-broadcasts';
    27         protected const string CAPABILITY = 'cancel_broadcasts';
     25        protected const NAME       = 'groundhogg/cancel-broadcast';
     26        protected const CATEGORY   = 'groundhogg-broadcasts';
     27        protected const CAPABILITY = 'cancel_broadcasts';
    2828
    29         protected const bool READONLY    = false;
    30         protected const bool DESTRUCTIVE = true;
    31         protected const bool IDEMPOTENT  = true;
     29        protected const READONLY    = false;
     30        protected const DESTRUCTIVE = true;
     31        protected const IDEMPOTENT  = true;
    3232
    3333        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/broadcasts/get-broadcast.php

    r3695315 r3695885  
    1616class Get_Broadcast extends Ability {
    1717
    18         protected const string NAME       = 'groundhogg/get-broadcast';
    19         protected const string CATEGORY   = 'groundhogg-broadcasts';
    20         protected const string CAPABILITY = 'view_broadcasts';
     18        protected const NAME       = 'groundhogg/get-broadcast';
     19        protected const CATEGORY   = 'groundhogg-broadcasts';
     20        protected const CAPABILITY = 'view_broadcasts';
    2121
    22         protected const bool READONLY   = true;
    23         protected const bool IDEMPOTENT = true;
     22        protected const READONLY   = true;
     23        protected const IDEMPOTENT = true;
    2424
    2525        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/broadcasts/list-broadcasts.php

    r3695315 r3695885  
    3737class List_Broadcasts extends Ability {
    3838
    39         protected const string NAME       = 'groundhogg/list-broadcasts';
    40         protected const string CATEGORY   = 'groundhogg-broadcasts';
    41         protected const string CAPABILITY = 'view_broadcasts';
    42 
    43         protected const bool READONLY   = true;
    44         protected const bool IDEMPOTENT = true;
     39        protected const NAME       = 'groundhogg/list-broadcasts';
     40        protected const CATEGORY   = 'groundhogg-broadcasts';
     41        protected const CAPABILITY = 'view_broadcasts';
     42
     43        protected const READONLY   = true;
     44        protected const IDEMPOTENT = true;
    4545
    4646        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/broadcasts/send-email-broadcast.php

    r3695315 r3695885  
    4141class Send_Email_Broadcast extends Ability {
    4242
    43         protected const string NAME       = 'groundhogg/send-email-broadcast';
    44         protected const string CATEGORY   = 'groundhogg-broadcasts';
    45         protected const string CAPABILITY = 'schedule_broadcasts';
    46 
    47         protected const bool READONLY    = false;
    48         protected const bool DESTRUCTIVE = true;
    49         protected const bool IDEMPOTENT  = false;
     43        protected const NAME       = 'groundhogg/send-email-broadcast';
     44        protected const CATEGORY   = 'groundhogg-broadcasts';
     45        protected const CAPABILITY = 'schedule_broadcasts';
     46
     47        protected const READONLY    = false;
     48        protected const DESTRUCTIVE = true;
     49        protected const IDEMPOTENT  = false;
    5050
    5151        protected function get_args(): array {
    … …  
    195195                }
    196196
     197                // The plain array form, not to_contact_query()'s live query object -
     198                // this gets stored (Broadcast::schedule()'s 'query' arg below) and, for
     199                // a "dynamic" segment_type, re-run later against whatever then matches;
     200                // a live query object (and anything a filter attached to it) can't
     201                // survive that round trip.
    197202                $query = Segment_Schema::to_query( $input );
    198203
  • groundhogg/trunk/includes/abilities/campaigns/list-campaigns.php

    r3695315 r3695885  
    99/**
    1010 * Lists the campaigns defined in Groundhogg (Contacts > Campaigns in the admin), so a caller
    11  * can discover valid campaign ids/names to pass as create-email's/update-email's campaigns param,
    12  * or to filter flows/broadcasts by campaign, without guessing.
     11 * can discover valid campaign ids/names to pass as create-email-template's/update-email-template's
     12 * campaigns param, or to filter flows/broadcasts/emails by campaign, without guessing.
    1313 */
    1414class List_Campaigns extends Ability {
    1515
    16         protected const string NAME       = 'groundhogg/list-campaigns';
    17         protected const string CATEGORY   = 'groundhogg-campaigns';
    18         protected const string CAPABILITY = 'manage_campaigns';
     16        protected const NAME       = 'groundhogg/list-campaigns';
     17        protected const CATEGORY   = 'groundhogg-campaigns';
     18        protected const CAPABILITY = 'manage_campaigns';
    1919
    20         protected const bool READONLY   = true;
    21         protected const bool IDEMPOTENT = true;
     20        protected const READONLY   = true;
     21        protected const IDEMPOTENT = true;
    2222
    2323        protected function get_args(): array {
    … …  
    2525                return [
    2626                        'label'       => __( 'List Campaigns', 'groundhogg' ),
    27                         'description' => __( 'List the campaigns defined in Groundhogg. Use the returned id or name with groundhogg/create-email\'s or groundhogg/update-email\'s campaigns param.', 'groundhogg' ),
     27                        'description' => __( 'List the campaigns defined in Groundhogg. Use the returned id or name with groundhogg/create-email-template\'s or groundhogg/update-email-template\'s campaigns param.', 'groundhogg' ),
    2828
    2929                        'input_schema' => [
  • groundhogg/trunk/includes/abilities/contacts/add-contact-note.php

    r3695315 r3695885  
    2020class Add_Contact_Note extends Ability {
    2121
    22         protected const string NAME       = 'groundhogg/add-contact-note';
    23         protected const string CATEGORY   = 'groundhogg-contacts';
    24         protected const string CAPABILITY = 'add_notes';
     22        protected const NAME       = 'groundhogg/add-contact-note';
     23        protected const CATEGORY   = 'groundhogg-contacts';
     24        protected const CAPABILITY = 'add_notes';
    2525
    26         protected const bool READONLY    = false;
    27         protected const bool DESTRUCTIVE = false;
    28         protected const bool IDEMPOTENT  = false;
     26        protected const READONLY    = false;
     27        protected const DESTRUCTIVE = false;
     28        protected const IDEMPOTENT  = false;
    2929
    3030        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/contacts/create-contact.php

    r3695315 r3695885  
    3434        use Has_Owner_Validation;
    3535
    36         protected const string NAME       = 'groundhogg/create-contact';
    37         protected const string CATEGORY   = 'groundhogg-contacts';
    38         protected const string CAPABILITY = 'add_contacts';
    39 
    40         protected const bool READONLY    = false;
    41         protected const bool DESTRUCTIVE = false;
    42         protected const bool IDEMPOTENT  = true;
     36        protected const NAME       = 'groundhogg/create-contact';
     37        protected const CATEGORY   = 'groundhogg-contacts';
     38        protected const CAPABILITY = 'add_contacts';
     39
     40        protected const READONLY    = false;
     41        protected const DESTRUCTIVE = false;
     42        protected const IDEMPOTENT  = true;
    4343
    4444        protected function get_args(): array {
    … …  
    8888                                                'items'       => [
    8989                                                        'type' => 'string',
    90                                                         'enum' => [ 'tags', 'meta' ],
     90                                                        'enum' => Contact_Schema::expand_options(),
    9191                                                ],
    9292                                                'default'     => [ 'tags' ],
    93                                                 'description' => __( 'Optional extra sections to expand on the returned contact. Available: tags, meta.', 'groundhogg' ),
     93                                                'description' => __( 'Optional extra sections to expand on the returned contact. Available: tags, meta, plus any sections an installed add-on has registered.', 'groundhogg' ),
    9494                                        ],
    9595                                ],
  • groundhogg/trunk/includes/abilities/contacts/get-contact.php

    r3695315 r3695885  
    1010class Get_Contact extends Ability {
    1111
    12         protected const string NAME       = 'groundhogg/get-contact';
    13         protected const string CATEGORY   = 'groundhogg-contacts';
    14         protected const string CAPABILITY = 'view_contacts';
     12        protected const NAME       = 'groundhogg/get-contact';
     13        protected const CATEGORY   = 'groundhogg-contacts';
     14        protected const CAPABILITY = 'view_contacts';
    1515
    16         protected const bool READONLY   = true;
    17         protected const bool IDEMPOTENT = true;
     16        protected const READONLY   = true;
     17        protected const IDEMPOTENT = true;
    1818
    1919        protected function get_args(): array {
    … …  
    4646                                                'items'       => [
    4747                                                        'type' => 'string',
    48                                                         'enum' => [ 'tags', 'meta' ],
     48                                                        'enum' => Contact_Schema::expand_options(),
    4949                                                ],
    5050                                                'default'     => [ 'tags' ],
    51                                                 'description' => __( 'Optional extra sections to expand beyond the standard contact fields. Available: tags, meta (raw custom field/meta values - see groundhogg/list-custom-fields to interpret them).', 'groundhogg' ),
     51                                                'description' => __( 'Optional extra sections to expand beyond the standard contact fields. Available: tags, meta (raw custom field/meta values - see groundhogg/list-custom-fields to interpret them), plus any sections an installed add-on has registered.', 'groundhogg' ),
    5252                                        ],
    5353                                ],
  • groundhogg/trunk/includes/abilities/contacts/list-contact-notes.php

    r3695315 r3695885  
    2626class List_Contact_Notes extends Ability {
    2727
    28         protected const string NAME       = 'groundhogg/list-contact-notes';
    29         protected const string CATEGORY   = 'groundhogg-contacts';
    30         protected const string CAPABILITY = 'view_notes';
     28        protected const NAME       = 'groundhogg/list-contact-notes';
     29        protected const CATEGORY   = 'groundhogg-contacts';
     30        protected const CAPABILITY = 'view_notes';
    3131
    32         protected const bool READONLY   = true;
    33         protected const bool IDEMPOTENT = true;
     32        protected const READONLY   = true;
     33        protected const IDEMPOTENT = true;
    3434
    3535        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/contacts/list-custom-fields.php

    r3695315 r3695885  
    1515class List_Custom_Fields extends Ability {
    1616
    17         protected const string NAME       = 'groundhogg/list-custom-fields';
    18         protected const string CATEGORY   = 'groundhogg-contacts';
    19         protected const string CAPABILITY = 'edit_contacts';
     17        protected const NAME       = 'groundhogg/list-custom-fields';
     18        protected const CATEGORY   = 'groundhogg-contacts';
     19        protected const CAPABILITY = 'edit_contacts';
    2020
    21         protected const bool READONLY   = true;
    22         protected const bool IDEMPOTENT = true;
     21        protected const READONLY   = true;
     22        protected const IDEMPOTENT = true;
    2323
    2424        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/contacts/list-owners.php

    r3695315 r3695885  
    1414class List_Owners extends Ability {
    1515
    16         protected const string NAME       = 'groundhogg/list-owners';
    17         protected const string CATEGORY   = 'groundhogg-contacts';
    18         protected const string CAPABILITY = 'view_contacts';
     16        protected const NAME       = 'groundhogg/list-owners';
     17        protected const CATEGORY   = 'groundhogg-contacts';
     18        protected const CAPABILITY = 'view_contacts';
    1919
    20         protected const bool READONLY   = true;
    21         protected const bool IDEMPOTENT = true;
     20        protected const READONLY   = true;
     21        protected const IDEMPOTENT = true;
    2222
    2323        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/contacts/list-saved-searches.php

    r3695315 r3695885  
    1313class List_Saved_Searches extends Ability {
    1414
    15         protected const string NAME       = 'groundhogg/list-saved-searches';
    16         protected const string CATEGORY   = 'groundhogg-contacts';
    17         protected const string CAPABILITY = 'view_contacts';
     15        protected const NAME       = 'groundhogg/list-saved-searches';
     16        protected const CATEGORY   = 'groundhogg-contacts';
     17        protected const CAPABILITY = 'view_contacts';
    1818
    19         protected const bool READONLY   = true;
    20         protected const bool IDEMPOTENT = true;
     19        protected const READONLY   = true;
     20        protected const IDEMPOTENT = true;
    2121
    2222        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/contacts/search-contacts.php

    r3695315 r3695885  
    66use Groundhogg\Abilities\Schemas\Contact_Schema;
    77use Groundhogg\Abilities\Schemas\Segment_Schema;
    8 use Groundhogg\Contact_Query;
    98use Throwable;
    109use WP_Error;
    … …  
    1211class Search_Contacts extends Ability {
    1312
    14         protected const string NAME       = 'groundhogg/search-contacts';
    15         protected const string CATEGORY   = 'groundhogg-contacts';
    16         protected const string CAPABILITY = 'view_contacts';
     13        protected const NAME       = 'groundhogg/search-contacts';
     14        protected const CATEGORY   = 'groundhogg-contacts';
     15        protected const CAPABILITY = 'view_contacts';
    1716
    18         protected const bool READONLY   = true;
    19         protected const bool IDEMPOTENT = true;
     17        protected const READONLY   = true;
     18        protected const IDEMPOTENT = true;
    2019
    2120        protected function get_args(): array {
    … …  
    3332                                                'items'       => [
    3433                                                        'type' => 'string',
    35                                                         'enum' => [ 'tags', 'meta' ],
     34                                                        // 'sql' is search-contacts' own debugging addition, not
     35                                                        // part of Contact_Schema::expand_options() (it's about
     36                                                        // the query as a whole, not a per-contact section, and
     37                                                        // wouldn't make sense on get-contact/create-contact/
     38                                                        // update-contact's single-row fetches).
     39                                                        'enum' => array_merge( Contact_Schema::expand_options(), [ 'sql' ] ),
    3640                                                ],
    3741                                                'default'     => [ 'tags' ],
    38                                                 'description' => __( 'Optional extra sections to expand on each contact, beyond the standard fields. Available: tags, meta (raw custom field/meta values - see groundhogg/list-custom-fields to interpret them).', 'groundhogg' ),
     42                                                'description' => __( 'Optional extra sections to expand, beyond the standard fields. Per-contact: tags, meta (raw custom field/meta values - see groundhogg/list-custom-fields to interpret them), plus any sections an installed add-on has registered. Query-level (not per-contact): "sql" adds a top-level `sql` field with the raw SQL Contact_Query built for this search - useful for debugging why a search matched (or didn\'t match) what was expected. Note it reflects the modern query builder\'s attempt specifically; if that attempt threw and Contact_Query silently fell back to its legacy query engine, this SQL will NOT be what actually ran.', 'groundhogg' ),
    3943                                        ],
    4044                                        'limit' => [
    … …  
    6569                                                'items' => Contact_Schema::get_schema(),
    6670                                        ],
     71                                        'sql' => [
     72                                                'type'        => 'string',
     73                                                'description' => __( 'Only present when "sql" is passed in expand. The raw SQL Contact_Query built for this search - see the `expand` input\'s description for a caveat about legacy-query fallback.', 'groundhogg' ),
     74                                        ],
    6775                                ],
    6876                        ],
    … …  
    7583                $offset = ! empty( $input['offset'] ) ? absint( $input['offset'] ) : 0;
    7684
    77                 $query_vars = Segment_Schema::to_query( $input );
     85                // Executes immediately below, so a live query object (to_contact_query())
     86                // rather than the plain array to_query() gives - lets an add-on hook
     87                // groundhogg/segment_schema/contact_query and manipulate the query
     88                // directly (joins, raw where conditions), and lets pagination be applied
     89                // here by just chaining onto it.
     90                $contact_query = Segment_Schema::to_contact_query( $input );
    7891
    79                 if ( is_wp_error( $query_vars ) ) {
    80                         return $query_vars;
     92                if ( is_wp_error( $contact_query ) ) {
     93                        return $contact_query;
    8194                }
    8295
    83                 $query_vars['number']     = $limit;
    84                 $query_vars['offset']     = $offset;
    85                 $query_vars['found_rows'] = true;
     96                $contact_query->setLimit( $limit )->setOffset( $offset )->setFoundRows( true );
    8697
    8798                try {
    88                         $contact_query = new Contact_Query();
    89                         $results       = $contact_query->query( $query_vars );
     99                        $results = $contact_query->query();
    90100                } catch ( Throwable $e ) {
    91101                        // Contact_Query's modern query building already falls back to a legacy query on
    … …  
    101111                }, $results );
    102112
    103                 return [
     113                $output = [
    104114                        'total_items' => $contact_query->found_items,
    105115                        'contacts'    => $contacts,
    106116                ];
     117
     118                if ( in_array( 'sql', $expand, true ) ) {
     119                        // Called after query() rather than before: get_sql()'s own
     120                        // maybe_setup_query() call is a no-op by this point (already run), so
     121                        // this reflects the exact query state query() actually built - not a
     122                        // fresh, possibly-different build. See the `expand` input's
     123                        // description for the legacy-fallback caveat.
     124                        $output['sql'] = $contact_query->get_sql();
     125                }
     126
     127                return $output;
    107128        }
    108129}
  • groundhogg/trunk/includes/abilities/contacts/update-contact.php

    r3695315 r3695885  
    2929        use Has_Owner_Validation;
    3030
    31         protected const string NAME       = 'groundhogg/update-contact';
    32         protected const string CATEGORY   = 'groundhogg-contacts';
    33         protected const string CAPABILITY = 'edit_contacts';
    34 
    35         protected const bool READONLY    = false;
    36         protected const bool DESTRUCTIVE = false;
    37         protected const bool IDEMPOTENT  = true;
     31        protected const NAME       = 'groundhogg/update-contact';
     32        protected const CATEGORY   = 'groundhogg-contacts';
     33        protected const CAPABILITY = 'edit_contacts';
     34
     35        protected const READONLY    = false;
     36        protected const DESTRUCTIVE = false;
     37        protected const IDEMPOTENT  = true;
    3838
    3939        protected function get_args(): array {
    … …  
    9393                                                'items'       => [
    9494                                                        'type' => 'string',
    95                                                         'enum' => [ 'tags', 'meta' ],
     95                                                        'enum' => Contact_Schema::expand_options(),
    9696                                                ],
    9797                                                'default'     => [ 'tags' ],
    98                                                 'description' => __( 'Optional extra sections to expand on the returned contact. Available: tags, meta.', 'groundhogg' ),
     98                                                'description' => __( 'Optional extra sections to expand on the returned contact. Available: tags, meta, plus any sections an installed add-on has registered.', 'groundhogg' ),
    9999                                        ],
    100100                                ],
  • groundhogg/trunk/includes/abilities/db/describe-table.php

    r3695315 r3695885  
    1515class Describe_Table extends Ability {
    1616
    17         protected const string NAME       = 'groundhogg/describe-table';
    18         protected const string CATEGORY   = 'groundhogg-db';
    19         protected const string CAPABILITY = 'manage_options';
     17        protected const NAME       = 'groundhogg/describe-table';
     18        protected const CATEGORY   = 'groundhogg-db';
     19        protected const CAPABILITY = 'manage_options';
    2020
    21         protected const bool READONLY   = true;
    22         protected const bool IDEMPOTENT = true;
     21        protected const READONLY   = true;
     22        protected const IDEMPOTENT = true;
    2323
    2424        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/db/query-table.php

    r3695315 r3695885  
    4040class Query_Table extends Ability {
    4141
    42         protected const string NAME       = 'groundhogg/query-table';
    43         protected const string CATEGORY   = 'groundhogg-db';
    44         protected const string CAPABILITY = 'manage_options';
    45 
    46         protected const bool READONLY   = true;
    47         protected const bool IDEMPOTENT = true;
     42        protected const NAME       = 'groundhogg/query-table';
     43        protected const CATEGORY   = 'groundhogg-db';
     44        protected const CAPABILITY = 'manage_options';
     45
     46        protected const READONLY   = true;
     47        protected const IDEMPOTENT = true;
    4848
    4949        /**
  • groundhogg/trunk/includes/abilities/emails/list-email-templates.php

    r3695315 r3695885  
    1919 * is almost never needed just to pick which email to send, so it is only
    2020 * returned when explicitly requested via `expand`. When expanding content, keep
    21  * `limit` small.
     21 * `limit` small, or use groundhogg/get-email-template to fetch one email's full
     22 * body directly once you have its id.
    2223 *
    2324 * `campaigns` filters via the generic object_relationships-backed 'related'
    2425 * query var (DB::query()) - an email is the primary/parent side of its
    2526 * relationship to a campaign, same direction Email::get_related_objects('campaign')
    26  * reads (see groundhogg/create-email's and groundhogg/update-email's campaigns param).
     27 * reads (see groundhogg/create-email-template's and groundhogg/update-email-template's
     28 * campaigns param).
    2729 */
    2830class List_Email_Templates extends Ability {
    2931
    30         protected const string NAME       = 'groundhogg/list-email-templates';
    31         protected const string CATEGORY   = 'groundhogg-email';
    32         protected const string CAPABILITY = 'view_emails';
     32        protected const NAME       = 'groundhogg/list-email-templates';
     33        protected const CATEGORY   = 'groundhogg-email';
     34        protected const CAPABILITY = 'view_emails';
    3335
    34         protected const bool READONLY   = true;
    35         protected const bool IDEMPOTENT = true;
     36        protected const READONLY   = true;
     37        protected const IDEMPOTENT = true;
    3638
    3739        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/emails/list-sender-profiles.php

    r3695315 r3695885  
    2222class List_Sender_Profiles extends Ability {
    2323
    24         protected const string NAME       = 'groundhogg/list-sender-profiles';
    25         protected const string CATEGORY   = 'groundhogg-email';
    26         protected const string CAPABILITY = 'send_emails';
     24        protected const NAME       = 'groundhogg/list-sender-profiles';
     25        protected const CATEGORY   = 'groundhogg-email';
     26        protected const CAPABILITY = 'send_emails';
    2727
    28         protected const bool READONLY   = true;
    29         protected const bool IDEMPOTENT = true;
     28        protected const READONLY   = true;
     29        protected const IDEMPOTENT = true;
    3030
    3131        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/emails/send-composed-email.php

    r3695315 r3695885  
    4343class Send_Composed_Email extends Ability {
    4444
    45         protected const string NAME       = 'groundhogg/send-composed-email';
    46         protected const string CATEGORY   = 'groundhogg-email';
    47         protected const string CAPABILITY = 'send_emails';
    48 
    49         protected const bool READONLY    = false;
    50         protected const bool DESTRUCTIVE = true;
    51         protected const bool IDEMPOTENT  = false;
     45        protected const NAME       = 'groundhogg/send-composed-email';
     46        protected const CATEGORY   = 'groundhogg-email';
     47        protected const CAPABILITY = 'send_emails';
     48
     49        protected const READONLY    = false;
     50        protected const DESTRUCTIVE = true;
     51        protected const IDEMPOTENT  = false;
    5252
    5353        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/emails/send-email-template.php

    r3695315 r3695885  
    3636class Send_Email_Template extends Ability {
    3737
    38         protected const string NAME       = 'groundhogg/send-email-template';
    39         protected const string CATEGORY   = 'groundhogg-email';
    40         protected const string CAPABILITY = 'send_emails';
    41 
    42         protected const bool READONLY    = false;
    43         protected const bool DESTRUCTIVE = true;
    44         protected const bool IDEMPOTENT  = false;
     38        protected const NAME       = 'groundhogg/send-email-template';
     39        protected const CATEGORY   = 'groundhogg-email';
     40        protected const CAPABILITY = 'send_emails';
     41
     42        protected const READONLY    = false;
     43        protected const DESTRUCTIVE = true;
     44        protected const IDEMPOTENT  = false;
    4545
    4646        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/funnels/add-to-flow.php

    r3695315 r3695885  
    3434class Add_To_Flow extends Ability {
    3535
    36         protected const string NAME       = 'groundhogg/add-to-flow';
    37         protected const string CATEGORY   = 'groundhogg-funnels';
    38         protected const string CAPABILITY = 'start_flows';
    39 
    40         protected const bool READONLY    = false;
    41         protected const bool DESTRUCTIVE = true;
    42         protected const bool IDEMPOTENT  = false;
     36        protected const NAME       = 'groundhogg/add-to-flow';
     37        protected const CATEGORY   = 'groundhogg-funnels';
     38        protected const CAPABILITY = 'start_flows';
     39
     40        protected const READONLY    = false;
     41        protected const DESTRUCTIVE = true;
     42        protected const IDEMPOTENT  = false;
    4343
    4444        protected function get_args(): array {
    … …  
    173173                }
    174174
     175                // The plain array form, not to_contact_query()'s live query object -
     176                // this gets stored and re-run in background batches (Background_Tasks::
     177                // add_contacts_to_funnel() below); a live query object (and anything a
     178                // filter attached to it) can't survive that round trip.
    175179                $query = Segment_Schema::to_query( $input );
    176180
  • groundhogg/trunk/includes/abilities/funnels/list-flows.php

    r3695315 r3695885  
    2121class List_Flows extends Ability {
    2222
    23         protected const string NAME       = 'groundhogg/list-flows';
    24         protected const string CATEGORY   = 'groundhogg-funnels';
    25         protected const string CAPABILITY = 'view_funnels';
     23        protected const NAME       = 'groundhogg/list-flows';
     24        protected const CATEGORY   = 'groundhogg-funnels';
     25        protected const CAPABILITY = 'view_funnels';
    2626
    27         protected const bool READONLY   = true;
    28         protected const bool IDEMPOTENT = true;
     27        protected const READONLY   = true;
     28        protected const IDEMPOTENT = true;
    2929
    3030        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/reports/get-reports.php

    r3695315 r3695885  
    3737class Get_Reports extends Ability {
    3838
    39         protected const string NAME       = 'groundhogg/get-reports';
    40         protected const string CATEGORY   = 'groundhogg-reports';
    41         protected const string CAPABILITY = 'view_reports';
    42 
    43         protected const bool READONLY   = true;
    44         protected const bool IDEMPOTENT = true;
     39        protected const NAME       = 'groundhogg/get-reports';
     40        protected const CATEGORY   = 'groundhogg-reports';
     41        protected const CAPABILITY = 'view_reports';
     42
     43        protected const READONLY   = true;
     44        protected const IDEMPOTENT = true;
    4545
    4646        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/reports/list-report-types.php

    r3695315 r3695885  
    1616class List_Report_Types extends Ability {
    1717
    18         protected const string NAME       = 'groundhogg/list-report-types';
    19         protected const string CATEGORY   = 'groundhogg-reports';
    20         protected const string CAPABILITY = 'view_reports';
     18        protected const NAME       = 'groundhogg/list-report-types';
     19        protected const CATEGORY   = 'groundhogg-reports';
     20        protected const CAPABILITY = 'view_reports';
    2121
    22         protected const bool READONLY   = true;
    23         protected const bool IDEMPOTENT = true;
     22        protected const READONLY   = true;
     23        protected const IDEMPOTENT = true;
    2424
    2525        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/schemas/contact-schema.php

    r3695315 r3695885  
    55use DateTime;
    66use Exception;
     7use Groundhogg\Abilities\Traits\Extensible_Schema;
    78use Groundhogg\Contact;
    89use Groundhogg\Preferences;
    … …  
    2425 * per install. Use the groundhogg/list-custom-fields ability to look up what each meta
    2526 * key actually means (label, type, group).
     27 *
     28 * Extensible via the Extensible_Schema trait, so an add-on can expose additional
     29 * read-only fields on groundhogg/get-contact and groundhogg/search-contacts
     30 * results without modifying this class or either of those abilities - one call
     31 * registers a new bounded-but-optional property (schema, expand option, and the
     32 * callback that computes it), the same "only when asked for" treatment
     33 * 'tags'/'meta' get above:
     34 *
     35 *     Contact_Schema::extend(
     36 *         'ltv',
     37 *         __( 'Lifetime value: total spend across all WooCommerce orders.', 'my-plugin' ),
     38 *         function ( Contact $contact ) {
     39 *             return (float) wc_get_customer_total_spent( $contact->get_user_id() );
     40 *         },
     41 *         'number'
     42 *     );
     43 *
     44 * Call this once (e.g. on `init`, after confirming both Groundhogg and the
     45 * dependency it needs are active) - before any ability builds its input/output
     46 * schema. See Extensible_Schema's own docblock for what extend() can't express
     47 * and the three underlying filters (`groundhogg/contact_schema/properties`,
     48 * `groundhogg/contact_schema/transform`, `groundhogg/contact_schema/expand_options`)
     49 * available for those cases.
    2650 */
    2751class Contact_Schema extends Schema {
    2852
     53        use Extensible_Schema;
     54
     55        protected static function extension_hook_prefix(): string {
     56                return 'contact_schema';
     57        }
     58
     59        protected static function builtin_property_keys(): array {
     60                return [ 'id', 'email', 'first_name', 'last_name', 'date_created', 'optin_status', 'tags', 'meta' ];
     61        }
     62
    2963        public static function get_schema(): array {
     64
     65                $properties = [
     66                        'id' => [
     67                                'type' => 'integer',
     68                        ],
     69                        'email' => [
     70                                'type'   => 'string',
     71                                'format' => 'email',
     72                        ],
     73                        'first_name' => [
     74                                'type' => 'string',
     75                        ],
     76                        'last_name' => [
     77                                'type' => 'string',
     78                        ],
     79                        'date_created' => [
     80                                'type'        => 'string',
     81                                'format'      => 'date-time',
     82                                'description' => __( 'When the contact was created.', 'groundhogg' ),
     83                        ],
     84                        'optin_status' => [
     85                                'type'        => 'object',
     86                                'description' => __( 'Whether/how the contact can be marketed to.', 'groundhogg' ),
     87                                'properties'  => [
     88                                        'value' => [
     89                                                'type'        => 'integer',
     90                                                'description' => __( 'The raw Groundhogg\Preferences status constant.', 'groundhogg' ),
     91                                        ],
     92                                        'label' => [
     93                                                'type' => 'string',
     94                                        ],
     95                                ],
     96                        ],
     97                        'tags' => [
     98                                'type'        => 'array',
     99                                'description' => __( 'Only present when "tags" is passed in include.', 'groundhogg' ),
     100                                'items'       => Tag_Schema::get_schema(),
     101                        ],
     102                        'meta' => [
     103                                'type'                 => 'object',
     104                                'description'          => __( 'Only present when "meta" is passed in include. Raw meta key => value pairs, including custom fields. See groundhogg/list-custom-fields to map keys to labels.', 'groundhogg' ),
     105                                'additionalProperties' => true,
     106                        ],
     107                ];
    30108
    31109                return [
    32110                        'type'       => 'object',
    33                         'properties' => [
    34                                 'id' => [
    35                                         'type' => 'integer',
    36                                 ],
    37                                 'email' => [
    38                                         'type'   => 'string',
    39                                         'format' => 'email',
    40                                 ],
    41                                 'first_name' => [
    42                                         'type' => 'string',
    43                                 ],
    44                                 'last_name' => [
    45                                         'type' => 'string',
    46                                 ],
    47                                 'date_created' => [
    48                                         'type'        => 'string',
    49                                         'format'      => 'date-time',
    50                                         'description' => __( 'When the contact was created.', 'groundhogg' ),
    51                                 ],
    52                                 'optin_status' => [
    53                                         'type'        => 'object',
    54                                         'description' => __( 'Whether/how the contact can be marketed to.', 'groundhogg' ),
    55                                         'properties'  => [
    56                                                 'value' => [
    57                                                         'type'        => 'integer',
    58                                                         'description' => __( 'The raw Groundhogg\Preferences status constant.', 'groundhogg' ),
    59                                                 ],
    60                                                 'label' => [
    61                                                         'type' => 'string',
    62                                                 ],
    63                                         ],
    64                                 ],
    65                                 'tags' => [
    66                                         'type'        => 'array',
    67                                         'description' => __( 'Only present when "tags" is passed in include.', 'groundhogg' ),
    68                                         'items'       => Tag_Schema::get_schema(),
    69                                 ],
    70                                 'meta' => [
    71                                         'type'                 => 'object',
    72                                         'description'          => __( 'Only present when "meta" is passed in include. Raw meta key => value pairs, including custom fields. See groundhogg/list-custom-fields to map keys to labels.', 'groundhogg' ),
    73                                         'additionalProperties' => true,
    74                                 ],
    75                         ],
     111                        'properties' => self::extend_properties( $properties ),
    76112                ];
     113        }
     114
     115        /**
     116         * The valid values for groundhogg/get-contact's and groundhogg/search-contacts'
     117         * own `expand` input parameter (their `enum`, not just this class's $include).
     118         * extend()'d keys are included automatically - see the class docblock for
     119         * the escape hatch when an add-on isn't going through extend().
     120         *
     121         * @return string[]
     122         */
     123        public static function expand_options(): array {
     124                return self::extend_expand_options( [ 'tags', 'meta' ] );
    77125        }
    78126
    … …  
    82130         *
    83131         * @param Contact|int|string|object $object
    84          * @param array                     $include Optional sections to include: 'tags', 'meta'.
     132         * @param array                     $include Optional sections to include: 'tags', 'meta',
     133         *                                           plus anything an add-on has registered via
     134         *                                           Contact_Schema::extend() (see the class docblock).
    85135         *
    86136         * @return array
    … …  
    124174                }
    125175
    126                 return $data;
     176                return self::extend_transform( $data, $object, $include );
    127177        }
    128178
  • groundhogg/trunk/includes/abilities/schemas/email-schema.php

    r3695315 r3695885  
    5555                                        'type'        => 'string',
    5656                                        'enum'        => [ 'html', 'blocks', 'legacy_blocks', 'legacy_plain' ],
    57                                         'description' => __( 'Which editor this email\'s content was built with, and which one wp-admin will open it in. "html": raw HTML (from the meta "type" flag, or content starting with "<!DOCTYPE"). "blocks": the current block editor (from the meta "blocks" flag) - content is HTML annotated with block comments ("<!--text:uuid {...}-->...<!--/text:uuid-->", etc.) that get parsed back into editable blocks. "legacy_blocks"/"legacy_plain": older formats inferred from the content itself. groundhogg/create-email\'s `editor` input controls which of "html"/"blocks" it produces.', 'groundhogg' ),
     57                                        'description' => __( 'Which editor this email\'s content was built with, and which one wp-admin will open it in. "html": raw HTML (from the meta "type" flag, or content starting with "<!DOCTYPE"). "blocks": the current block editor (from the meta "blocks" flag) - content is HTML annotated with block comments ("<!--text:uuid {...}-->...<!--/text:uuid-->", etc.) that get parsed back into editable blocks. "legacy_blocks"/"legacy_plain": older formats inferred from the content itself. groundhogg/create-email-template\'s `editor` input controls which of "html"/"blocks" it produces.', 'groundhogg' ),
    5858                                ],
    5959                                'template_settings' => [
    6060                                        'type'        => 'object',
    61                                         'description' => __( 'The outer page layout/background this email renders inside of - distinct from the block/HTML content itself. Mirrors the "Template Settings" panel in wp-admin\'s email editor. Set via groundhogg/create-email\'s or groundhogg/update-email\'s `template_settings` input.', 'groundhogg' ),
     61                                        'description' => __( 'The outer page layout/background this email renders inside of - distinct from the block/HTML content itself. Mirrors the "Template Settings" panel in wp-admin\'s email editor. Set via groundhogg/create-email-template\'s or groundhogg/update-email-template\'s `template_settings` input.', 'groundhogg' ),
    6262                                        'properties'  => self::template_settings_properties(),
    6363                                ],
    … …  
    100100         * The `template_settings` object's properties - shared by the output
    101101         * schema above and the `template_settings` input on
    102          * groundhogg/create-email and groundhogg/update-email, so the property
    103          * names, enums, and descriptions can't drift out of sync between them.
     102         * groundhogg/create-email-template and groundhogg/update-email-template, so
     103         * the property names, enums, and descriptions can't drift out of sync
     104         * between them.
    104105         *
    105106         * @param bool $with_defaults Include `default` values for "layout" and
    106          *                            "direction" - appropriate for create-email,
     107         *                            "direction" - appropriate for create-email-template,
    107108         *                            where a brand-new email needs a sensible
    108109         *                            starting point. Leave false for the output
    109          *                            schema and for update-email, where an absent
    110          *                            property should be left untouched rather
     110         *                            schema and for update-email-template, where an
     111         *                            absent property should be left untouched rather
    111112         *                            than implying it would reset to a default.
    112113         *
  • groundhogg/trunk/includes/abilities/schemas/segment-schema.php

    r3695315 r3695885  
    44
    55use Groundhogg\Abilities\Traits\Has_Optin_Status;
     6use Groundhogg\Contact_Query;
    67use WP_Error;
    78use function Groundhogg\parse_tag_list;
    … …  
    2526 * describes *input*, not an output shape, so it has no get_schema()/transform().
    2627 *
    27  *   // in get_args():
    28  *   'properties' => array_merge( Segment_Schema::properties(), [ ...ability-specific... ] ),
    29  *
    30  *   // in __invoke():
     28 * Three ways to turn a validated $input into something queryable, depending on
     29 * what the calling ability actually does with it:
     30 *
     31 *   // Executes immediately (groundhogg/search-contacts): a live Contact_Query,
     32 *   // so the caller can keep chaining (setLimit(), setOrderby(), ...) and an
     33 *   // add-on can manipulate the query object directly - see
     34 *   // groundhogg/segment_schema/contact_query below.
     35 *   $contact_query = Segment_Schema::to_contact_query( $input );
     36 *   if ( is_wp_error( $contact_query ) ) { return $contact_query; }
     37 *   $results = $contact_query->setLimit( $limit )->setFoundRows( true )->query();
     38 *
     39 *   // Stored and (re-)run later (groundhogg/send-email-broadcast's dynamic
     40 *   // segments, groundhogg/add-to-flow's background batches): a plain,
     41 *   // serializable array of query vars - a live query object (and whatever a
     42 *   // filter attached to it via addJoin()/where()) can't survive that round
     43 *   // trip.
    3144 *   $query = Segment_Schema::to_query( $input );
    3245 *   if ( is_wp_error( $query ) ) { return $query; }
    33  *   // ... hand $query to Contact_Query / Broadcast::schedule() / ...
     46 *   // ... hand $query to Broadcast::schedule() / Background_Tasks / ...
     47 *
     48 *   // A branch/logic step's own condition setting (e.g. if_else's
     49 *   // `include_filters`) - Groundhogg's Filters DSL directly, not a
     50 *   // Contact_Query-vars array at all.
     51 *   $filters = Segment_Schema::to_filters( $input );
     52 *   if ( is_wp_error( $filters ) ) { return $filters; }
     53 *   // ... use as an if_else/branch-logic step's include_filters setting
     54 *
     55 *   // in get_args(), for all three:
     56 *   'properties' => array_merge( Segment_Schema::properties(), [ ...ability-specific... ] ),
     57 *
     58 * Extensible, so an add-on can add its own audience-filtering params to every
     59 * ability built on this schema (groundhogg/search-contacts,
     60 * groundhogg/send-email-broadcast, groundhogg/add-to-flow, ...) without modifying
     61 * this class or any of them individually.
     62 *
     63 * The easy way in is Segment_Schema::extend() - one call registers a new input
     64 * property (its schema) and a callback that turns it into Groundhogg's Filters
     65 * DSL (the same condition format Contact_Query's own `include_filters` query var
     66 * and branch-logic step settings like if_else's `include_filters` consume - see
     67 * `Groundhogg\DB\Query\Filters` and Contact_Query's `filter_*` methods for the
     68 * full vocabulary of condition `type`s). Returning filter conditions rather than
     69 * mutating a query-vars array directly means one extend() call works for BOTH
     70 * to_query()/to_contact_query() (folded into `include_filters`) AND to_filters()
     71 * (used for branch/logic conditions) - not two things to hook separately:
     72 *
     73 *     Segment_Schema::extend(
     74 *         'woocommerce',
     75 *         [
     76 *             'type'                 => 'object',
     77 *             'additionalProperties' => false,
     78 *             'properties'           => [
     79 *                 'min_orders' => [
     80 *                     'type'        => 'integer',
     81 *                     'description' => __( 'Only contacts with at least this many WooCommerce orders.', 'my-plugin' ),
     82 *                 ],
     83 *             ],
     84 *         ],
     85 *         function ( array $input ): array {
     86 *             if ( empty( $input['woocommerce']['min_orders'] ) ) {
     87 *                 return [];
     88 *             }
     89 *             // Requires a filter type actually registered with Contact_Query's
     90 *             // Filters instance (via groundhogg/contact_query/filters/register) -
     91 *             // extend() only carries conditions through, it doesn't invent new
     92 *             // filter types on its own.
     93 *             return [ [
     94 *                 'type'    => 'woocommerce_orders',
     95 *                 'compare' => 'greater_than_or_equal_to',
     96 *                 'value'   => absint( $input['woocommerce']['min_orders'] ),
     97 *             ] ];
     98 *         },
     99 *         true // this alone counts as naming a deliberate audience - see has_audience()
     100 *     );
     101 *
     102 * Call this once (e.g. on `init`, after confirming both Groundhogg and the
     103 * dependency it needs are active) - before any ability builds its input schema.
     104 *
     105 * For query logic no Filters condition type can express at all (a join, a raw
     106 * where condition, a custom select), reach for to_contact_query()'s live
     107 * Contact_Query object instead, via the `groundhogg/segment_schema/contact_query`
     108 * action below - only usable by abilities that execute the query themselves
     109 * rather than storing it (see to_contact_query()'s own docblock).
     110 *
     111 * Everything extend() does is built on filters, also available directly for
     112 * anything extend() can't express (e.g. contributing to more than one property
     113 * from a single registration):
     114 *
     115 * - `groundhogg/segment_schema/properties` filters the input properties (properties(),
     116 *   below).
     117 * - `groundhogg/segment_schema/query` filters the plain Contact_Query-vars array
     118 *   built from $input (to_query(), below) - runs after extend()'d conditions are
     119 *   already folded into `include_filters`.
     120 * - `groundhogg/segment_schema/filters` filters the Filters-DSL array built from
     121 *   $input (to_filters(), below) - same timing, after extend()'d conditions are
     122 *   already merged in.
     123 * - `groundhogg/segment_schema/audience_keys` adds to the keys has_audience() treats
     124 *   as deliberately naming a group of contacts, so an ability that must not act on
     125 *   the whole database by accident still recognizes a request that only supplies
     126 *   an add-on's own param as targeted, not "everyone".
    34127 */
    35128class Segment_Schema {
    … …  
    57150
    58151        /**
    59          * The input_schema `properties` for a contact segment. Every condition is
    60          * ANDed together. Spread these into an ability's own `properties`.
     152         * Add-on-registered input properties, keyed by property name. Populated by
     153         * extend().
     154         *
     155         * @var array<string, array{schema: array, callback: callable, audience: bool}>
     156         */
     157        private static array $extensions = [];
     158
     159        /**
     160         * Register an additional audience-filtering input property - see the class
     161         * docblock for the full picture. One call adds the property to the input
     162         * schema (properties(), below) and wires the callback that turns it into
     163         * Filters-DSL conditions, merged into `include_filters` for to_query()/
     164         * to_contact_query() and into the result of to_filters() alike - instead of
     165         * hooking groundhogg/segment_schema/properties and (one or both of)
     166         * groundhogg/segment_schema/query / groundhogg/segment_schema/filters by hand.
     167         *
     168         * @param string   $key             The property name, e.g. "woocommerce". Must
     169         *                                  not collide with a built-in name or an
     170         *                                  existing extend()'d one - either is refused
     171         *                                  with _doing_it_wrong() rather than silently
     172         *                                  overwriting something else already relying
     173         *                                  on that key.
     174         * @param array    $schema          The full JSON schema fragment for this
     175         *                                  property (type, description, enum,
     176         *                                  properties, items, ...), used as-is. Unlike
     177         *                                  Contact_Schema::extend()'s simpler
     178         *                                  description+type shorthand, a segment
     179         *                                  filter's shape varies too much (an object
     180         *                                  with its own sub-properties, an enum, a
     181         *                                  plain scalar, ...) to usefully paper over.
     182         * @param callable $callback        function( array $input ): array|WP_Error.
     183         *                                  Receives the full $input and returns a flat
     184         *                                  list of Filters-DSL condition arrays (each
     185         *                                  `['type' => ..., ...]`) contributed by this
     186         *                                  property - empty array if $key isn't present
     187         *                                  in $input - or a WP_Error to reject the
     188         *                                  input outright, same as to_query()'s own
     189         *                                  tags_include handling. The `type` used must
     190         *                                  be a filter type actually registered with
     191         *                                  Contact_Query's Filters instance (see the
     192         *                                  class docblock) - extend() carries
     193         *                                  conditions through, it doesn't register new
     194         *                                  filter types itself. Called on every
     195         *                                  to_query()/to_contact_query()/to_filters(),
     196         *                                  regardless of whether $key is actually
     197         *                                  present - check $input[ $key ] yourself.
     198         * @param bool     $is_audience_key Whether supplying this property alone (with
     199         *                                  no other segment param) should count as
     200         *                                  deliberately naming a group of contacts -
     201         *                                  see has_audience(). Default false.
     202         *
     203         * @return void
     204         */
     205        public static function extend( string $key, array $schema, callable $callback, bool $is_audience_key = false ) {
     206
     207                if ( array_key_exists( $key, self::base_properties() ) || isset( self::$extensions[ $key ] ) ) {
     208                        _doing_it_wrong(
     209                                __METHOD__,
     210                                sprintf( 'A Segment_Schema property named "%s" already exists.', $key ),
     211                                '4.8'
     212                        );
     213
     214                        return;
     215                }
     216
     217                self::$extensions[ $key ] = [
     218                        'schema'   => $schema,
     219                        'callback' => $callback,
     220                        'audience' => $is_audience_key,
     221                ];
     222        }
     223
     224        /**
     225         * This schema's own built-in input properties, before add-on extensions or
     226         * the groundhogg/segment_schema/properties filter. Split out from
     227         * properties() so extend() can check a proposed key against it without
     228         * re-running the filter (and without properties() needing to special-case
     229         * its own extensions when checking for collisions).
    61230         *
    62231         * @return array
    63232         */
    64         public static function properties(): array {
     233        private static function base_properties(): array {
     234
    65235                return [
    66236                        'search' => [
    … …  
    179349
    180350        /**
     351         * The input_schema `properties` for a contact segment. Every condition is
     352         * ANDed together. Spread these into an ability's own `properties`.
     353         *
     354         * @return array
     355         */
     356        public static function properties(): array {
     357
     358                $properties = self::base_properties();
     359
     360                foreach ( self::$extensions as $key => $extension ) {
     361                        $properties[ $key ] = $extension['schema'];
     362                }
     363
     364                // See the class docblock for how an add-on adds its own properties here.
     365                return apply_filters( 'groundhogg/segment_schema/properties', $properties );
     366        }
     367
     368        /**
    181369         * Whether $input names a deliberate group of contacts (vs. "everyone" or just
    182370         * refinements). An ability that must not act on the whole database by
    … …  
    189377        public static function has_audience( array $input ): bool {
    190378
    191                 foreach ( self::AUDIENCE_KEYS as $key ) {
     379                $audience_keys = self::AUDIENCE_KEYS;
     380
     381                foreach ( self::$extensions as $key => $extension ) {
     382                        if ( $extension['audience'] ) {
     383                                $audience_keys[] = $key;
     384                        }
     385                }
     386
     387                // See the class docblock for how an add-on adds its own key here.
     388                $audience_keys = apply_filters( 'groundhogg/segment_schema/audience_keys', $audience_keys );
     389
     390                foreach ( $audience_keys as $key ) {
    192391                        if ( ! empty( $input[ $key ] ) ) {
    193392                                return true;
    … …  
    349548                }
    350549
    351                 return $query;
     550                $extension_conditions = self::extension_filter_conditions( $input );
     551
     552                if ( is_wp_error( $extension_conditions ) ) {
     553                        return $extension_conditions;
     554                }
     555
     556                if ( ! empty( $extension_conditions ) ) {
     557                        // Merged into the same single AND-group `include_filters` already uses
     558                        // elsewhere in this method (see to_filters() for the built-in fields'
     559                        // equivalent) - Contact_Query natively understands `include_filters`
     560                        // alongside the native query vars above (db.php's `case 'include_filters'`),
     561                        // so this doesn't conflict with anything already in $query.
     562                        $query['include_filters'][0] = array_merge(
     563                                $query['include_filters'][0] ?? [],
     564                                $extension_conditions
     565                        );
     566                }
     567
     568                // See the class docblock for how an add-on translates its own input
     569                // properties into query vars here. Only reached on success - the early
     570                // WP_Error returns above (e.g. unknown tags, an extend()'d callback
     571                // rejecting its own input) bypass this filter, same as any other hard
     572                // validation failure.
     573                return apply_filters( 'groundhogg/segment_schema/query', $query, $input );
     574        }
     575
     576        /**
     577         * Run every extend()'d property's callback against $input and collect the
     578         * Filters-DSL conditions they contribute, for merging into either
     579         * to_query()'s `include_filters` or to_filters()'s result.
     580         *
     581         * @param array $input
     582         *
     583         * @return array|WP_Error A flat list of condition arrays (possibly empty), or
     584         *                        the first WP_Error a callback returns.
     585         */
     586        private static function extension_filter_conditions( array $input ) {
     587
     588                $conditions = [];
     589
     590                foreach ( self::$extensions as $key => $extension ) {
     591
     592                        $result = call_user_func( $extension['callback'], $input );
     593
     594                        if ( is_wp_error( $result ) ) {
     595                                return $result;
     596                        }
     597
     598                        if ( ! empty( $result ) ) {
     599                                array_push( $conditions, ...array_values( $result ) );
     600                        }
     601                }
     602
     603                return $conditions;
     604        }
     605
     606        /**
     607         * Maps a `meta` condition's `compare` (this class's own vocabulary, matching
     608         * Where::compare() - see META_COMPARISONS) to the equivalent
     609         * Groundhogg\DB\Query\Filters::string() compare keyword, for to_filters().
     610         * The two vocabularies grew independently and mostly agree, except:
     611         * "in"/"not_in" here must become "any_of"/"none_of" for Filters::string() -
     612         * passed through unmapped, Filters::string() silently compares against only
     613         * the first array value instead of the whole list, since it only special-cases
     614         * exactly "any_of"/"none_of" before falling back to a scalar comparison.
     615         * "like"/"not_like" have no exact Filters::string() equivalent and are mapped
     616         * to the closest substring match ("contains"/"not_contains").
     617         */
     618        private const META_COMPARE_TO_FILTER_COMPARE = [
     619                'equals'                   => 'equals',
     620                'not_equals'               => 'not_equals',
     621                'less_than'                => 'less_than',
     622                'greater_than'             => 'greater_than',
     623                'less_than_or_equal_to'    => 'less_than_or_equal_to',
     624                'greater_than_or_equal_to' => 'greater_than_or_equal_to',
     625                'in'                       => 'any_of',
     626                'not_in'                   => 'none_of',
     627                'like'                     => 'contains',
     628                'not_like'                 => 'not_contains',
     629                'contains'                 => 'contains',
     630                'not_contains'             => 'not_contains',
     631                'starts_with'              => 'starts_with',
     632                'ends_with'                => 'ends_with',
     633                'empty'                    => 'empty',
     634                'not_empty'                => 'not_empty',
     635        ];
     636
     637        /**
     638         * Build Groundhogg's Filters DSL (Groundhogg\DB\Query\Filters::sanitize()'s
     639         * expected shape - an OR-of-AND condition list, e.g.
     640         * `[[ {type:'tags', ...}, {type:'owner', ...} ]]`) from a validated segment
     641         * $input. This is the format branch/logic step settings consume directly
     642         * (e.g. if_else's `include_filters`/`exclude_filters`) - unlike to_query()/
     643         * to_contact_query(), the result here is NOT a Contact_Query-vars array, and
     644         * running it requires a Contact_Query (or a step type) that understands the
     645         * Filters DSL, not a raw query.
     646         *
     647         * Every translated condition lands in a single AND-group (`[[ ...all of
     648         * them... ]]`), matching Segment_Schema's own "every condition ANDed
     649         * together" semantics - to_query()'s notion of ANDing everything, expressed
     650         * here as one AND-group with no OR alternatives.
     651         *
     652         * `meta_relation: OR` is expressed via the `sub_query` filter type
     653         * (Contact_Query::filter_sub_query()), which takes its own nested
     654         * `include_filters` (a full OR-of-AND array) and applies it inside a
     655         * sub-Where group - exactly the primitive needed to nest an OR *inside* the
     656         * single outer AND-group everything else lands in, without restructuring the
     657         * whole condition list or duplicating every other condition into each OR
     658         * branch. See the `meta` handling below.
     659         *
     660         * One known gap: `search` has no Filters equivalent (Contact_Query's own
     661         * `search` handling is a query-var-level heuristic - email vs. full name -
     662         * not a Filters condition) and is silently ignored here. Use
     663         * to_query()/to_contact_query() if `search` needs to actually apply.
     664         *
     665         * @param array $input
     666         *
     667         * @return array|WP_Error
     668         */
     669        public static function to_filters( array $input ) {
     670
     671                $conditions = [];
     672
     673                if ( ! empty( $input['include'] ) ) {
     674                        $conditions[] = [
     675                                'type'    => 'contact_id',
     676                                'compare' => 'any_of',
     677                                'value'   => wp_parse_id_list( $input['include'] ),
     678                        ];
     679                }
     680
     681                if ( ! empty( $input['exclude'] ) ) {
     682                        $conditions[] = [
     683                                'type'    => 'contact_id',
     684                                'compare' => 'none_of',
     685                                'value'   => wp_parse_id_list( $input['exclude'] ),
     686                        ];
     687                }
     688
     689                if ( ! empty( $input['tags_include'] ) ) {
     690
     691                        // $create = false: resolving an audience must never mint a tag.
     692                        $resolved = parse_tag_list( $input['tags_include'], 'ID', false );
     693
     694                        if ( empty( $resolved ) ) {
     695                                return new WP_Error(
     696                                        'groundhogg_unknown_tags',
     697                                        __( 'None of the tags in tags_include match an existing tag.', 'groundhogg' )
     698                                );
     699                        }
     700
     701                        $conditions[] = [
     702                                'type'     => 'tags',
     703                                'tags'     => $resolved,
     704                                'compare'  => 'includes',
     705                                'compare2' => ! empty( $input['tags_include_needs_all'] ) ? 'all' : 'any',
     706                        ];
     707                }
     708
     709                if ( ! empty( $input['tags_exclude'] ) ) {
     710
     711                        $resolved = parse_tag_list( $input['tags_exclude'], 'ID', false );
     712
     713                        if ( ! empty( $resolved ) ) {
     714                                $conditions[] = [
     715                                        'type'     => 'tags',
     716                                        'tags'     => $resolved,
     717                                        'compare'  => 'excludes',
     718                                        'compare2' => ! empty( $input['tags_exclude_needs_all'] ) ? 'all' : 'any',
     719                                ];
     720                        }
     721                }
     722
     723                if ( ! empty( $input['optin_status'] ) && is_array( $input['optin_status'] ) ) {
     724                        $conditions[] = [
     725                                'type'    => 'optin_status',
     726                                'value'   => self::resolve_optin_status_list( $input['optin_status'] ),
     727                                'compare' => 'in',
     728                        ];
     729                }
     730
     731                if ( ! empty( $input['optin_status_exclude'] ) && is_array( $input['optin_status_exclude'] ) ) {
     732                        $conditions[] = [
     733                                'type'    => 'optin_status',
     734                                'value'   => self::resolve_optin_status_list( $input['optin_status_exclude'] ),
     735                                'compare' => 'not_in',
     736                        ];
     737                }
     738
     739                if ( isset( $input['marketable'] ) ) {
     740                        $conditions[] = [
     741                                'type'       => 'is_marketable',
     742                                'marketable' => $input['marketable'] ? 'yes' : 'no',
     743                        ];
     744                }
     745
     746                if ( ! empty( $input['owner'] ) ) {
     747                        $conditions[] = [
     748                                'type'    => 'owner',
     749                                'value'   => wp_parse_id_list( $input['owner'] ),
     750                                'compare' => 'in',
     751                        ];
     752                }
     753
     754                if ( ! empty( $input['users_include'] ) ) {
     755                        $conditions[] = [
     756                                'type'    => 'user_id',
     757                                'value'   => wp_parse_id_list( $input['users_include'] ),
     758                                'compare' => 'any_of',
     759                        ];
     760                }
     761
     762                if ( ! empty( $input['users_exclude'] ) ) {
     763                        $conditions[] = [
     764                                'type'    => 'user_id',
     765                                'value'   => wp_parse_id_list( $input['users_exclude'] ),
     766                                'compare' => 'none_of',
     767                        ];
     768                }
     769
     770                if ( isset( $input['has_user'] ) ) {
     771                        if ( $input['has_user'] ) {
     772                                $conditions[] = [ 'type' => 'is_user' ];
     773                        } else {
     774                                // No negated "is_user" filter type exists - approximate "no linked
     775                                // user" as user_id being exactly the unset value.
     776                                $conditions[] = [ 'type' => 'user_id', 'compare' => 'equals', 'value' => 0 ];
     777                        }
     778                }
     779
     780                if ( ! empty( $input['created_after'] ) && ! empty( $input['created_before'] ) ) {
     781                        $conditions[] = [
     782                                'type'       => 'date_created',
     783                                'date_range' => 'between',
     784                                'after'      => sanitize_text_field( $input['created_after'] ),
     785                                'before'     => sanitize_text_field( $input['created_before'] ),
     786                        ];
     787                } else if ( ! empty( $input['created_after'] ) ) {
     788                        $conditions[] = [
     789                                'type'       => 'date_created',
     790                                'date_range' => 'after',
     791                                'after'      => sanitize_text_field( $input['created_after'] ),
     792                        ];
     793                } else if ( ! empty( $input['created_before'] ) ) {
     794                        $conditions[] = [
     795                                'type'       => 'date_created',
     796                                'date_range' => 'before',
     797                                'before'     => sanitize_text_field( $input['created_before'] ),
     798                        ];
     799                }
     800
     801                if ( ! empty( $input['saved_search'] ) ) {
     802                        $conditions[] = [
     803                                'type'    => 'saved_search',
     804                                'search'  => sanitize_text_field( $input['saved_search'] ),
     805                                'compare' => 'in',
     806                        ];
     807                }
     808
     809                if ( ! empty( $input['meta'] ) && is_array( $input['meta'] ) ) {
     810
     811                        $meta_conditions = [];
     812
     813                        foreach ( $input['meta'] as $condition ) {
     814
     815                                $condition = (array) $condition;
     816                                $key       = isset( $condition['key'] ) ? sanitize_key( $condition['key'] ) : '';
     817
     818                                if ( ! $key ) {
     819                                        continue;
     820                                }
     821
     822                                $compare = in_array( $condition['compare'] ?? 'equals', self::META_COMPARISONS, true )
     823                                        ? $condition['compare']
     824                                        : 'equals';
     825
     826                                $meta_conditions[] = [
     827                                        'type'    => 'meta',
     828                                        'meta'    => $key,
     829                                        'compare' => self::META_COMPARE_TO_FILTER_COMPARE[ $compare ] ?? 'equals',
     830                                        'value'   => $condition['value'] ?? '',
     831                                ];
     832                        }
     833
     834                        $is_or = ! empty( $input['meta_relation'] ) && strtoupper( $input['meta_relation'] ) === 'OR';
     835
     836                        if ( $is_or && count( $meta_conditions ) > 1 ) {
     837                                // Nest as one OR-of-AND sub-group inside the outer AND-group, via
     838                                // the sub_query filter type - see the class docblock for why this
     839                                // is the only way to express an OR here without restructuring
     840                                // everything else. Each meta condition becomes its own single-item
     841                                // AND-group, ORed together.
     842                                $conditions[] = [
     843                                        'type'            => 'sub_query',
     844                                        'include_filters' => array_map( static fn( $condition ) => [ $condition ], $meta_conditions ),
     845                                ];
     846                        } else {
     847                                // Either explicitly ANDed, or only one condition (where AND/OR are
     848                                // equivalent) - no need for the sub_query wrapper, just AND them
     849                                // into the outer group directly like everything else.
     850                                array_push( $conditions, ...$meta_conditions );
     851                        }
     852                }
     853
     854                $extension_conditions = self::extension_filter_conditions( $input );
     855
     856                if ( is_wp_error( $extension_conditions ) ) {
     857                        return $extension_conditions;
     858                }
     859
     860                array_push( $conditions, ...$extension_conditions );
     861
     862                // See the class docblock for how an add-on adds its own conditions here.
     863                return apply_filters( 'groundhogg/segment_schema/filters', empty( $conditions ) ? [] : [ $conditions ], $input );
     864        }
     865
     866        /**
     867         * Build a live Contact_Query from a validated segment $input - same
     868         * conditions as to_query(), but as a query object the caller can keep
     869         * building on (setLimit(), setOrderby(), ...) and an add-on can manipulate
     870         * directly via `groundhogg/segment_schema/contact_query` for anything a
     871         * plain query-var array can't express (a join, a raw where condition).
     872         * See the class docblock for when to reach for this vs. to_query().
     873         *
     874         * Does not run the query - the caller decides when (query(), get_results(),
     875         * count(), ...) and with what pagination/ordering on top.
     876         *
     877         * @param array $input
     878         * @param int   $flags Passed through to Contact_Query's constructor (e.g.
     879         *                     Contact_Query::IS_SUB_QUERY).
     880         *
     881         * @return Contact_Query|WP_Error
     882         */
     883        public static function to_contact_query( array $input, int $flags = 0 ) {
     884
     885                $query_vars = self::to_query( $input );
     886
     887                if ( is_wp_error( $query_vars ) ) {
     888                        return $query_vars;
     889                }
     890
     891                $contact_query = new Contact_Query( $query_vars, $flags );
     892
     893                /**
     894                 * Fires with the live Contact_Query built from a segment $input, so an
     895                 * add-on can manipulate it directly - see the class docblock's
     896                 * WooCommerce example. The query hasn't run yet, so anything applied
     897                 * here (addJoin(), where(), setSelect(), ...) takes effect normally.
     898                 *
     899                 * @param Contact_Query $contact_query
     900                 * @param array         $input
     901                 */
     902                do_action( 'groundhogg/segment_schema/contact_query', $contact_query, $input );
     903
     904                return $contact_query;
    352905        }
    353906
  • groundhogg/trunk/includes/abilities/tags/list-tags.php

    r3695315 r3695885  
    1414class List_Tags extends Ability {
    1515
    16         protected const string NAME       = 'groundhogg/list-tags';
    17         protected const string CATEGORY   = 'groundhogg-tags';
    18         protected const string CAPABILITY = 'manage_tags';
     16        protected const NAME       = 'groundhogg/list-tags';
     17        protected const CATEGORY   = 'groundhogg-tags';
     18        protected const CAPABILITY = 'manage_tags';
    1919
    20         protected const bool READONLY   = true;
    21         protected const bool IDEMPOTENT = true;
     20        protected const READONLY   = true;
     21        protected const IDEMPOTENT = true;
    2222
    2323        protected function get_args(): array {
  • groundhogg/trunk/includes/abilities/utils/upload-media.php

    r3695315 r3695885  
    99 * Uploads a file into the WordPress Media Library and returns its ID, URL,
    1010 * and relative path - the missing piece for building an email (via
    11  * groundhogg/create-email or groundhogg/update-email) that includes a real
    12  * image rather than a placeholder: upload the image first, then use the
    13  * returned `url` as an `image` block's `src` (see the groundhogg-block-email
    14  * skill) or a plain `<img>` src in HTML content.
     11 * groundhogg/create-email-template or groundhogg/update-email-template) that
     12 * includes a real image rather than a placeholder: upload the image first,
     13 * then use the returned `url` as an `image` block's `src` (see the
     14 * groundhogg-block-email skill) or a plain `<img>` src in HTML content.
    1515 *
    1616 * `data` is base64, not a raw file upload - abilities are called with a JSON
    … …  
    3737class Upload_Media extends Ability {
    3838
    39         protected const string NAME       = 'groundhogg/upload-media';
    40         protected const string CATEGORY   = 'groundhogg-utils';
    41         protected const string CAPABILITY = 'upload_files';
     39        protected const NAME       = 'groundhogg/upload-media';
     40        protected const CATEGORY   = 'groundhogg-utils';
     41        protected const CAPABILITY = 'upload_files';
    4242
    43         protected const bool READONLY    = false;
    44         protected const bool DESTRUCTIVE = false;
    45         protected const bool IDEMPOTENT  = false;
     43        protected const READONLY    = false;
     44        protected const DESTRUCTIVE = false;
     45        protected const IDEMPOTENT  = false;
    4646
    4747        protected function get_args(): array {
    … …  
    4949                return [
    5050                        'label'       => __( 'Upload Media', 'groundhogg' ),
    51                         'description' => __( 'Upload a file (e.g. an image) into the WordPress Media Library from base64 data. Returns the attachment\'s id, URL, and relative path, for use as an image src elsewhere - e.g. an image block in groundhogg/create-email\'s content.', 'groundhogg' ),
     51                        'description' => __( 'Upload a file (e.g. an image) into the WordPress Media Library from base64 data. Returns the attachment\'s id, URL, and relative path, for use as an image src elsewhere - e.g. an image block in groundhogg/create-email-template\'s content.', 'groundhogg' ),
    5252
    5353                        'input_schema' => [
Note: See TracChangeset for help on using the changeset viewer.