Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.15% covered (success)
95.15%
255 / 268
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Social_Settings_Abilities
95.15% covered (success)
95.15%
255 / 268
62.50% covered (warning)
62.50%
5 / 8
39
0.00% covered (danger)
0.00%
0 / 1
 get_category_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_category_definition
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_abilities
100.00% covered (success)
100.00%
132 / 132
100.00% covered (success)
100.00%
1 / 1
1
 can_manage_settings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_settings
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 update_settings
97.59% covered (success)
97.59%
81 / 83
0.00% covered (danger)
0.00%
0 / 1
21
 build_settings_snapshot
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
7
 set_publicize_module_active
44.44% covered (danger)
44.44%
8 / 18
0.00% covered (danger)
0.00%
0 / 1
12.17
1<?php
2/**
3 * Jetpack Social Settings Abilities Registration.
4 *
5 * Registers Jetpack Social settings abilities with the WordPress Abilities API
6 * so AI agents can read and update the site-wide auto-share, share-message, and
7 * image-generator defaults through the standard `wp-abilities/v1` REST surface.
8 *
9 * This lives in the Publicize package — shared by both the Jetpack plugin
10 * (Social/Publicize module) and the standalone Jetpack Social plugin — so the
11 * settings abilities register in every context where Social is available, not
12 * only in the standalone plugin. Registration is wired from
13 * `Publicize_Setup::pre_initialization()`.
14 *
15 * @package automattic/jetpack-publicize
16 */
17
18// @phan-file-suppress PhanUndeclaredFunction, PhanUndeclaredClassMethod @phan-suppress-current-line UnusedSuppression -- Abilities API added in WP 6.9; suppressions for older-WP compatibility runs.
19
20namespace Automattic\Jetpack\Publicize\Abilities;
21
22use Automattic\Jetpack\Modules;
23use Automattic\Jetpack\Publicize\Jetpack_Social_Settings\Settings as Social_Settings;
24use Automattic\Jetpack\Publicize\Social_Image_Generator\Templates;
25use Automattic\Jetpack\WP_Abilities\Registrar;
26use Jetpack_Social;
27use WP_Error;
28
29/**
30 * Registers Jetpack Social settings abilities.
31 *
32 * Exposes a small, agent-friendly settings surface:
33 *
34 * - `jetpack-social/get-settings`    — read the current site-wide Social
35 *   settings (auto-share toggle, share message template, image generator
36 *   defaults, UTM defaults, social notes toggle).
37 * - `jetpack-social/update-settings` — update one or more fields; idempotent
38 *   when the desired state already matches.
39 *
40 * Storage is delegated to the publicize package's `Settings` class (which
41 * owns the persisted options) and to `Automattic\Jetpack\Modules` (which
42 * owns the publicize module on/off switch).
43 */
44class Social_Settings_Abilities extends Registrar {
45
46    const CATEGORY_SLUG = 'jetpack-social';
47    const ERROR_PREFIX  = 'jetpack_social_';
48
49    /**
50     * {@inheritDoc}
51     */
52    public static function get_category_slug(): string {
53        return self::CATEGORY_SLUG;
54    }
55
56    /**
57     * {@inheritDoc}
58     */
59    public static function get_category_definition(): array {
60        return array(
61            // "Jetpack Social" is a product name and should not be translated.
62            'label'       => 'Jetpack Social',
63            'description' => __( 'Abilities for reading and updating Jetpack Social settings.', 'jetpack-publicize-pkg' ),
64        );
65    }
66
67    /**
68     * {@inheritDoc}
69     */
70    public static function get_abilities(): array {
71        $settings_schema = array(
72            'type'       => 'object',
73            'properties' => array(
74                'auto_share_enabled'       => array(
75                    'type'        => 'boolean',
76                    'description' => __( 'Whether auto-sharing (the Publicize module) is enabled.', 'jetpack-publicize-pkg' ),
77                ),
78                'share_message_template'   => array(
79                    'type'        => 'string',
80                    'description' => __( 'Site-wide default share message template. Supports {title}, {excerpt}, and {url} placeholders.', 'jetpack-publicize-pkg' ),
81                ),
82                'image_generator_enabled'  => array(
83                    'type'        => 'boolean',
84                    'description' => __( 'Whether the Social Image Generator is enabled by default for new posts.', 'jetpack-publicize-pkg' ),
85                ),
86                'image_generator_template' => array(
87                    'type'        => array( 'string', 'null' ),
88                    'enum'        => array_merge( Templates::TEMPLATES, array( null ) ),
89                    'description' => __( 'The default Social Image Generator template slug. Null when the feature is not available.', 'jetpack-publicize-pkg' ),
90                ),
91                'utm_enabled'              => array(
92                    'type'        => 'boolean',
93                    'description' => __( 'Whether UTM parameters are appended to shared links.', 'jetpack-publicize-pkg' ),
94                ),
95                'social_notes_enabled'     => array(
96                    'type'        => 'boolean',
97                    'description' => __( 'Whether the Social Notes short-form custom post type is enabled.', 'jetpack-publicize-pkg' ),
98                ),
99                'supports'                 => array(
100                    'type'       => 'object',
101                    'properties' => array(
102                        'image_generator' => array( 'type' => 'boolean' ),
103                        'utm'             => array( 'type' => 'boolean' ),
104                        'social_notes'    => array( 'type' => 'boolean' ),
105                    ),
106                ),
107            ),
108        );
109
110        $update_input_schema = array(
111            'type'                 => 'object',
112            'default'              => array(),
113            'additionalProperties' => false,
114            'properties'           => array(
115                'auto_share_enabled'       => array(
116                    'type'        => 'boolean',
117                    'description' => __( 'Enable or disable the Publicize module (auto-sharing).', 'jetpack-publicize-pkg' ),
118                ),
119                'share_message_template'   => array(
120                    'type'        => 'string',
121                    'description' => __( 'Site-wide default share message template. Empty string clears the template.', 'jetpack-publicize-pkg' ),
122                    'maxLength'   => Social_Settings::MESSAGE_TEMPLATE_MAX_LENGTH,
123                ),
124                'image_generator_enabled'  => array(
125                    'type'        => 'boolean',
126                    'description' => __( 'Enable or disable the Social Image Generator by default.', 'jetpack-publicize-pkg' ),
127                ),
128                'image_generator_template' => array(
129                    'type'        => 'string',
130                    'enum'        => Templates::TEMPLATES,
131                    'minLength'   => 1,
132                    'description' => __( 'Set the default Social Image Generator template slug.', 'jetpack-publicize-pkg' ),
133                ),
134                'utm_enabled'              => array(
135                    'type'        => 'boolean',
136                    'description' => __( 'Enable or disable UTM parameters on shared links.', 'jetpack-publicize-pkg' ),
137                ),
138                'social_notes_enabled'     => array(
139                    'type'        => 'boolean',
140                    'description' => __( 'Enable or disable the Social Notes custom post type.', 'jetpack-publicize-pkg' ),
141                ),
142            ),
143        );
144
145        $update_output_schema = array(
146            'type'       => 'object',
147            'properties' => array(
148                'settings'       => $settings_schema,
149                'changed'        => array(
150                    'type'        => 'boolean',
151                    'description' => __( 'True when at least one stored value was updated.', 'jetpack-publicize-pkg' ),
152                ),
153                'changed_fields' => array(
154                    'type'        => 'array',
155                    'items'       => array( 'type' => 'string' ),
156                    'description' => __( 'Keys whose stored value changed.', 'jetpack-publicize-pkg' ),
157                ),
158            ),
159        );
160
161        return array(
162            'jetpack-social/get-settings'    => array(
163                'label'               => __( 'Get Jetpack Social settings', 'jetpack-publicize-pkg' ),
164                'description'         => __(
165                    'Return the site-wide Jetpack Social settings: { auto_share_enabled, share_message_template, image_generator_enabled, image_generator_template, utm_enabled, social_notes_enabled, supports }. Read-only and idempotent. Requires the administrator capability (`manage_options`) — the same cap the plugin\'s REST settings controller enforces.',
166                    'jetpack-publicize-pkg'
167                ),
168                'input_schema'        => array(
169                    'type'                 => 'object',
170                    'default'              => array(),
171                    'additionalProperties' => false,
172                    'properties'           => array(),
173                ),
174                'output_schema'       => $settings_schema,
175                'execute_callback'    => array( __CLASS__, 'get_settings' ),
176                'permission_callback' => array( __CLASS__, 'can_manage_settings' ),
177                'meta'                => array(
178                    'annotations'  => array(
179                        'readonly'    => true,
180                        'destructive' => false,
181                        'idempotent'  => true,
182                    ),
183                    'show_in_rest' => true,
184                ),
185            ),
186
187            'jetpack-social/update-settings' => array(
188                'label'               => __( 'Update Jetpack Social settings', 'jetpack-publicize-pkg' ),
189                'description'         => __(
190                    'Update one or more Jetpack Social settings. Accepts any subset of { auto_share_enabled, share_message_template, image_generator_enabled, image_generator_template, utm_enabled, social_notes_enabled }. Returns the resulting settings plus { changed, changed_fields }. Idempotent — when the desired values already match the stored values, returns changed=false. Requires the administrator capability (`manage_options`).',
191                    'jetpack-publicize-pkg'
192                ),
193                'input_schema'        => $update_input_schema,
194                'output_schema'       => $update_output_schema,
195                'execute_callback'    => array( __CLASS__, 'update_settings' ),
196                'permission_callback' => array( __CLASS__, 'can_manage_settings' ),
197                'meta'                => array(
198                    'annotations'  => array(
199                        'readonly'    => false,
200                        'destructive' => false,
201                        'idempotent'  => true,
202                    ),
203                    'show_in_rest' => true,
204                ),
205            ),
206        );
207    }
208
209    /**
210     * Permission check for both abilities.
211     *
212     * Mirrors `REST_Settings_Controller::require_admin_privilege_callback()` —
213     * only administrators can read or modify plugin-wide settings.
214     *
215     * @return bool
216     */
217    public static function can_manage_settings(): bool {
218        return current_user_can( 'manage_options' );
219    }
220
221    /**
222     * Execute: read the current settings.
223     *
224     * @param array|null $input Unused — get-settings takes no parameters, but the
225     *                          Abilities API still passes the decoded payload, so
226     *                          accept it and discard.
227     * @return array
228     */
229    public static function get_settings( $input = null ) {
230        unset( $input );
231        return self::build_settings_snapshot();
232    }
233
234    /**
235     * Execute: update one or more settings.
236     *
237     * @param array|null $input Input matching the ability's input_schema.
238     * @return array|WP_Error
239     */
240    public static function update_settings( $input = null ) {
241        $input = is_array( $input ) ? $input : array();
242
243        // Empty payload: no-op, but a valid call. Return current settings with changed=false.
244        if ( empty( $input ) ) {
245            return array(
246                'settings'       => self::build_settings_snapshot(),
247                'changed'        => false,
248                'changed_fields' => array(),
249            );
250        }
251
252        $before         = self::build_settings_snapshot();
253        $changed_fields = array();
254
255        foreach ( $input as $field => $value ) {
256            switch ( $field ) {
257                case 'auto_share_enabled':
258                    $desired = (bool) $value;
259                    if ( $desired === (bool) $before['auto_share_enabled'] ) {
260                        break;
261                    }
262                    $result = self::set_publicize_module_active( $desired );
263                    if ( is_wp_error( $result ) ) {
264                        return $result;
265                    }
266                    $changed_fields[] = $field;
267                    break;
268
269                case 'share_message_template':
270                    $desired = Social_Settings::sanitize_message_template( $value );
271                    if ( $desired === (string) $before['share_message_template'] ) {
272                        break;
273                    }
274                    update_option(
275                        Social_Settings::OPTION_PREFIX . Social_Settings::MESSAGE_TEMPLATE,
276                        $desired
277                    );
278                    $changed_fields[] = $field;
279                    break;
280
281                case 'image_generator_enabled':
282                    $desired = (bool) $value;
283                    if ( $desired === (bool) $before['image_generator_enabled'] ) {
284                        break;
285                    }
286                    $settings_instance = new Social_Settings();
287                    $settings_instance->update_social_image_generator_settings( array( 'enabled' => $desired ) );
288                    $changed_fields[] = $field;
289                    break;
290
291                case 'image_generator_template':
292                    if ( ! is_string( $value ) || ! in_array( $value, Templates::TEMPLATES, true ) ) {
293                        return new WP_Error(
294                            self::ERROR_PREFIX . 'invalid_image_generator_template',
295                            sprintf(
296                                /* translators: %s is a comma-separated list of valid template slugs. */
297                                __( 'image_generator_template must be one of: %s.', 'jetpack-publicize-pkg' ),
298                                implode( ', ', Templates::TEMPLATES )
299                            ),
300                            array( 'status' => 400 )
301                        );
302                    }
303                    $desired = $value;
304                    if ( $desired === (string) $before['image_generator_template'] ) {
305                        break;
306                    }
307                    $settings_instance = new Social_Settings();
308                    $settings_instance->update_social_image_generator_settings( array( 'template' => $desired ) );
309                    $changed_fields[] = $field;
310                    break;
311
312                case 'utm_enabled':
313                    $desired = (bool) $value;
314                    if ( $desired === (bool) $before['utm_enabled'] ) {
315                        break;
316                    }
317                    $current_utm = get_option(
318                        Social_Settings::OPTION_PREFIX . Social_Settings::UTM_SETTINGS,
319                        Social_Settings::DEFAULT_UTM_SETTINGS
320                    );
321                    if ( ! is_array( $current_utm ) ) {
322                        $current_utm = Social_Settings::DEFAULT_UTM_SETTINGS;
323                    }
324                    $current_utm['enabled'] = $desired;
325                    update_option( Social_Settings::OPTION_PREFIX . Social_Settings::UTM_SETTINGS, $current_utm );
326                    $changed_fields[] = $field;
327                    break;
328
329                case 'social_notes_enabled':
330                    $desired = (bool) $value;
331                    if ( $desired === (bool) $before['social_notes_enabled'] ) {
332                        break;
333                    }
334                    // Mirror Settings::update_settings(): flush the rewrite-rules cache.
335                    delete_option( Social_Settings::NOTES_FLUSH_REWRITE_RULES_FLUSHED );
336                    update_option( Social_Settings::JETPACK_SOCIAL_NOTE_CPT_ENABLED, $desired );
337                    $changed_fields[] = $field;
338                    break;
339
340                default:
341                    // additionalProperties:false in the schema prevents this in REST, but
342                    // guard for direct PHP callers.
343                    break;
344            }
345        }
346
347        $after = self::build_settings_snapshot();
348
349        return array(
350            'settings'       => $after,
351            'changed'        => ! empty( $changed_fields ),
352            'changed_fields' => array_values( array_unique( $changed_fields ) ),
353        );
354    }
355
356    /**
357     * Build the canonical snapshot returned by both abilities.
358     *
359     * @return array
360     */
361    private static function build_settings_snapshot(): array {
362        $settings_instance = new Social_Settings();
363
364        $sig_settings     = $settings_instance->get_image_generator_settings();
365        $utm_settings     = $settings_instance->get_utm_settings();
366        $utm_enabled      = is_array( $utm_settings ) && ! empty( $utm_settings['enabled'] );
367        $auto_share_on    = class_exists( Jetpack_Social::class )
368            ? Jetpack_Social::is_publicize_active()
369            : ( new Modules() )->is_active( 'publicize' );
370        $notes_enabled    = (bool) get_option( Social_Settings::JETPACK_SOCIAL_NOTE_CPT_ENABLED, false );
371        $has_sig_feature  = (bool) $settings_instance->is_sig_available();
372        $message_template = $settings_instance->get_message_template();
373
374        // Match Jetpack_Social_Settings\Settings::get_settings(): SIG cannot be
375        // enabled without Publicize, and the template is only meaningful when
376        // the SIG feature is available.
377        $sig_enabled  = ! empty( $sig_settings['enabled'] ) && (bool) $auto_share_on;
378        $sig_template = $has_sig_feature && isset( $sig_settings['template'] ) && is_string( $sig_settings['template'] )
379            ? $sig_settings['template']
380            : null;
381
382        return array(
383            'auto_share_enabled'       => (bool) $auto_share_on,
384            'share_message_template'   => $message_template,
385            'image_generator_enabled'  => $sig_enabled,
386            'image_generator_template' => $sig_template,
387            'utm_enabled'              => $utm_enabled,
388            'social_notes_enabled'     => $notes_enabled,
389            'supports'                 => array(
390                'image_generator' => $has_sig_feature,
391                'utm'             => true,
392                'social_notes'    => true,
393            ),
394        );
395    }
396
397    /**
398     * Activate or deactivate the publicize module.
399     *
400     * Wraps `Modules::activate()` / `Modules::deactivate()` so the
401     * `auto_share_enabled` field maps to the plugin's auto-sharing master
402     * switch in one place.
403     *
404     * @param bool $enabled Desired state.
405     * @return true|WP_Error True on success; WP_Error if the modules layer rejects the change.
406     */
407    private static function set_publicize_module_active( bool $enabled ) {
408        $modules = new Modules();
409        $slug    = class_exists( Jetpack_Social::class )
410            ? Jetpack_Social::JETPACK_PUBLICIZE_MODULE_SLUG
411            : 'publicize';
412
413        $result = $enabled
414            ? $modules->activate( $slug, false, false )
415            : $modules->deactivate( $slug );
416
417        if ( is_wp_error( $result ) ) {
418            return $result;
419        }
420
421        if ( false === $result ) {
422            return new WP_Error(
423                self::ERROR_PREFIX . 'module_toggle_failed',
424                $enabled
425                    ? __( 'Failed to activate the Publicize module.', 'jetpack-publicize-pkg' )
426                    : __( 'Failed to deactivate the Publicize module.', 'jetpack-publicize-pkg' ),
427                array( 'status' => 500 )
428            );
429        }
430
431        return true;
432    }
433}