PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 15.8.1
Jetpack – WP Security, Backup, Speed, & Growth v15.8.1
12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 14.2.2 14.3.1 14.4.2 14.5.1 14.6.1 14.7.1 14.8.1 14.9.2 15.0.3 15.1.2 15.2.1 15.3.2 15.4.1 15.5.1 15.6.1 15.7.2 15.8.1 15.9.2 16.0.2 16.1.3 16.2-a.5 16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / modules / subscriptions / abilities / class-newsletter-abilities.php
jetpack / modules / subscriptions / abilities Last commit date
class-newsletter-abilities.php 4 months ago
class-newsletter-abilities.php
533 lines
1 <?php
2 /**
3 * Jetpack Newsletter Abilities Registration
4 *
5 * Registers Jetpack Newsletter (subscriptions) abilities with the WordPress
6 * Abilities API.
7 *
8 * @package automattic/jetpack
9 */
10
11 // @phan-file-suppress PhanUndeclaredFunction, PhanUndeclaredClassMethod @phan-suppress-current-line UnusedSuppression -- Abilities API added in WP 6.9.
12
13 namespace Automattic\Jetpack\Plugin\Abilities;
14
15 use Automattic\Jetpack\Connection\Client;
16 use Automattic\Jetpack\Modules\Subscriptions\Settings as Subscriptions_Settings;
17 use Automattic\Jetpack\WP_Abilities\Registrar;
18 use Jetpack;
19 use Jetpack_Options;
20
21 // The Subscriptions module doesn't load its Settings helpers eagerly. Pull it
22 // in here so `Subscriptions_Settings::$default_reply_to` and
23 // `is_valid_reply_to()` are resolvable when the abilities run.
24 require_once __DIR__ . '/../class-settings.php';
25
26 /**
27 * Registers Jetpack Newsletter abilities with the WordPress Abilities API.
28 *
29 * Exposes a consolidated read of the site's newsletter (subscriptions) settings
30 * and a partial-update writer so AI agents can configure the Newsletter module
31 * through the standard `wp-abilities/v1` REST surface.
32 */
33 class Newsletter_Abilities extends Registrar {
34
35 // Field type tags used in `settings_map()`. Constants (not strings) so a
36 // typo in a `case` label fails fast instead of silently falling through.
37 private const TYPE_BOOL = 'bool';
38 private const TYPE_ON_OFF = 'on_off';
39 private const TYPE_ENUM = 'enum';
40 private const TYPE_STRING = 'string';
41
42 /**
43 * Allowed values for the `reply_to` setting. Mirror of
44 * `Subscriptions_Settings::is_valid_reply_to()` — kept here so it can be
45 * referenced from the JSON Schema enum without loading the Settings class
46 * at file-parse time.
47 */
48 private const REPLY_TO_VALUES = array( 'comment', 'author', 'no-reply' );
49
50 /**
51 * Returns the abilities category, definition, or registered abilities.
52 *
53 * @inheritDoc
54 */
55 public static function get_category_slug(): string {
56 return 'jetpack-newsletter';
57 }
58
59 /**
60 * Returns the abilities category, definition, or registered abilities.
61 *
62 * @inheritDoc
63 */
64 public static function get_category_definition(): array {
65 return array(
66 // "Jetpack" and "Newsletter" are product names and should not be translated.
67 'label' => 'Jetpack Newsletter',
68 'description' => __( 'Abilities for reading and updating Jetpack Newsletter settings.', 'jetpack' ),
69 );
70 }
71
72 /**
73 * Returns the abilities category, definition, or registered abilities.
74 *
75 * @inheritDoc
76 */
77 public static function get_abilities(): array {
78 $settings_object_schema = array(
79 'type' => 'object',
80 'additionalProperties' => false,
81 'properties' => array(
82 'subscribe_post_end_enabled' => array(
83 'type' => 'boolean',
84 'description' => __( 'Show a "subscribe to blog" checkbox at the end of every post. Default true.', 'jetpack' ),
85 ),
86 'subscribe_comments_enabled' => array(
87 'type' => 'boolean',
88 'description' => __( 'Show a "notify me of new comments" checkbox in the comment form. Default true.', 'jetpack' ),
89 ),
90 'notify_admin_on_subscribe' => array(
91 'type' => 'boolean',
92 'description' => __( 'Email the site admin whenever a new subscriber signs up. Default true.', 'jetpack' ),
93 ),
94 'reply_to' => array(
95 'type' => 'string',
96 'enum' => self::REPLY_TO_VALUES,
97 'description' => __( 'Reply-to address for newsletter emails. "comment" routes to the post comment author, "author" to the post author, "no-reply" disables replies. Default "comment".', 'jetpack' ),
98 ),
99 'from_name' => array(
100 'type' => 'string',
101 'description' => __( 'Sender name shown on newsletter emails. Empty string falls back to the site name.', 'jetpack' ),
102 'maxLength' => 200,
103 ),
104 ),
105 );
106
107 return array(
108 'jetpack-newsletter/get-settings' => array(
109 'label' => __( 'Get Newsletter settings', 'jetpack' ),
110 'description' => __(
111 'Return the current Jetpack Newsletter settings as a flat object. Always returns the same five fields: subscribe_post_end_enabled (bool), subscribe_comments_enabled (bool), notify_admin_on_subscribe (bool), reply_to ("comment"|"author"|"no-reply"), and from_name (string). Read-only and idempotent. To change any value, call jetpack-newsletter/update-settings.',
112 'jetpack'
113 ),
114 'input_schema' => array(
115 'type' => 'object',
116 'default' => array(),
117 'properties' => array(),
118 'additionalProperties' => false,
119 ),
120 'output_schema' => $settings_object_schema,
121 'execute_callback' => array( __CLASS__, 'get_settings' ),
122 'permission_callback' => array( __CLASS__, 'can_view_settings' ),
123 'meta' => array(
124 'annotations' => array(
125 'readonly' => true,
126 'destructive' => false,
127 'idempotent' => true,
128 ),
129 'show_in_rest' => true,
130 ),
131 ),
132
133 'jetpack-newsletter/update-settings' => array(
134 'label' => __( 'Update Newsletter settings', 'jetpack' ),
135 'description' => __(
136 'Update one or more Jetpack Newsletter settings. Any subset of the five fields may be supplied; omitted fields are left untouched. Idempotent — fields whose desired value already matches the current value are not rewritten. Returns { settings: <full current state after the update>, changed: <array of field names that actually transitioned> }. An empty input or input matching the current state returns changed = [].',
137 'jetpack'
138 ),
139 'input_schema' => $settings_object_schema,
140 'output_schema' => array(
141 'type' => 'object',
142 'properties' => array(
143 'settings' => $settings_object_schema,
144 'changed' => array(
145 'type' => 'array',
146 'items' => array( 'type' => 'string' ),
147 'description' => __( 'Names of the fields that actually changed during this call. Empty when the call was a no-op.', 'jetpack' ),
148 ),
149 ),
150 ),
151 'execute_callback' => array( __CLASS__, 'update_settings' ),
152 'permission_callback' => array( __CLASS__, 'can_manage_settings' ),
153 'meta' => array(
154 'annotations' => array(
155 'readonly' => false,
156 'destructive' => false,
157 'idempotent' => true,
158 ),
159 'show_in_rest' => true,
160 ),
161 ),
162
163 'jetpack-newsletter/get-subscriber-stats' => array(
164 'label' => __( 'Get Newsletter subscriber stats', 'jetpack' ),
165 'description' => __(
166 'Return aggregate subscriber counts for the site. Always returns { all: int, email: int, paid: int }: all is the total subscriber count (email + WordPress.com followers); email is the subset that receives email; paid is the subset on a paid newsletter plan. Numbers are fetched from WordPress.com and cached locally for one hour, so transient network errors yield a stale-but-non-zero response when one is available. Requires an active Jetpack connection — sites without one return jetpack_newsletter_not_connected.',
167 'jetpack'
168 ),
169 'input_schema' => array(
170 'type' => 'object',
171 'default' => array(),
172 'properties' => array(),
173 'additionalProperties' => false,
174 ),
175 'output_schema' => array(
176 'type' => 'object',
177 'properties' => array(
178 'all' => array( 'type' => 'integer' ),
179 'email' => array( 'type' => 'integer' ),
180 'paid' => array( 'type' => 'integer' ),
181 ),
182 ),
183 'execute_callback' => array( __CLASS__, 'get_subscriber_stats' ),
184 'permission_callback' => array( __CLASS__, 'can_view_settings' ),
185 'meta' => array(
186 'annotations' => array(
187 'readonly' => true,
188 'destructive' => false,
189 'idempotent' => true,
190 ),
191 'show_in_rest' => true,
192 ),
193 ),
194 );
195 }
196
197 /**
198 * Permission check for read abilities. Newsletter settings live on the WP
199 * Newsletter settings screen, which is itself gated on `manage_options`.
200 */
201 public static function can_view_settings(): bool {
202 return current_user_can( 'manage_options' );
203 }
204
205 /**
206 * Permission check for write abilities. Mirrors the gating on the
207 * Newsletter settings screen.
208 */
209 public static function can_manage_settings(): bool {
210 return current_user_can( 'manage_options' );
211 }
212
213 /**
214 * Execute: return the current newsletter settings.
215 *
216 * @param array|null $input Unused — input schema accepts no parameters.
217 * @return array
218 */
219 public static function get_settings( $input = null ): array {
220 unset( $input );
221 return self::current_settings();
222 }
223
224 /**
225 * Transient key for the wpcom subscriber-stats response.
226 */
227 private const SUBSCRIBER_STATS_CACHE_KEY = 'jetpack_newsletter_subscriber_stats';
228
229 /**
230 * Transient TTL for subscriber-stats responses, in seconds.
231 *
232 * Matches the existing legacy widget pattern of an hour-long cache so
233 * agents calling this ability repeatedly don't fan out to wpcom.
234 */
235 private const SUBSCRIBER_STATS_CACHE_TTL = HOUR_IN_SECONDS;
236
237 /**
238 * Execute: fetch (and cache) aggregate subscriber counts from WordPress.com.
239 *
240 * @param array|null $input Unused — input schema accepts no parameters.
241 * @return array|\WP_Error
242 */
243 public static function get_subscriber_stats( $input = null ) {
244 unset( $input );
245
246 $cached = get_transient( self::SUBSCRIBER_STATS_CACHE_KEY );
247 if ( is_array( $cached ) ) {
248 return $cached;
249 }
250
251 if ( ! class_exists( 'Jetpack' ) || ! Jetpack::is_connection_ready() ) {
252 return new \WP_Error(
253 'jetpack_newsletter_not_connected',
254 __( 'Subscriber stats are only available on Jetpack-connected sites. Connect Jetpack and retry.', 'jetpack' )
255 );
256 }
257
258 $site_id = (int) Jetpack_Options::get_option( 'id' );
259 if ( $site_id <= 0 ) {
260 return new \WP_Error(
261 'jetpack_newsletter_not_connected',
262 __( 'No Jetpack site ID is registered. Connect Jetpack and retry.', 'jetpack' )
263 );
264 }
265
266 $response = Client::wpcom_json_api_request_as_blog(
267 sprintf( '/sites/%d/subscribers/stats', $site_id ),
268 '2',
269 array(),
270 null,
271 'wpcom'
272 );
273
274 if ( is_wp_error( $response ) ) {
275 return new \WP_Error(
276 'jetpack_newsletter_subscriber_stats_unavailable',
277 $response->get_error_message()
278 );
279 }
280
281 if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
282 return new \WP_Error(
283 'jetpack_newsletter_subscriber_stats_unavailable',
284 __( 'WordPress.com did not return subscriber stats. Retry shortly.', 'jetpack' )
285 );
286 }
287
288 $body = json_decode( wp_remote_retrieve_body( $response ), true );
289 $counts = is_array( $body ) && isset( $body['counts'] ) && is_array( $body['counts'] )
290 ? $body['counts']
291 : array();
292
293 $stats = array(
294 'all' => isset( $counts['all_subscribers'] ) ? (int) $counts['all_subscribers'] : 0,
295 'email' => isset( $counts['email_subscribers'] ) ? (int) $counts['email_subscribers'] : 0,
296 'paid' => isset( $counts['paid_subscribers'] ) ? (int) $counts['paid_subscribers'] : 0,
297 );
298
299 set_transient( self::SUBSCRIBER_STATS_CACHE_KEY, $stats, self::SUBSCRIBER_STATS_CACHE_TTL );
300
301 return $stats;
302 }
303
304 /**
305 * Execute: idempotent partial update of newsletter settings.
306 *
307 * Validates every supplied field before writing anything, so a malformed
308 * field cannot leave the option set in a partially-updated state.
309 *
310 * @param array|null $input Input matching the ability's input_schema.
311 * @return array|\WP_Error
312 */
313 public static function update_settings( $input = null ) {
314 $input = is_array( $input ) ? $input : array();
315 $map = self::settings_map();
316
317 // Validate + normalize every supplied field up-front. Any failure
318 // short-circuits the call with no writes, so a bad field can't leave
319 // earlier fields in a partially-updated state.
320 $normalized = array();
321 foreach ( $map as $field => $config ) {
322 if ( ! array_key_exists( $field, $input ) ) {
323 continue;
324 }
325
326 $result = self::normalize_input_value( $field, $config, $input[ $field ] );
327 if ( $result instanceof \WP_Error ) {
328 return $result;
329 }
330 $normalized[ $field ] = $result;
331 }
332
333 // Read every field's current value once. This pass also feeds the
334 // post-update response, avoiding a second `get_option` sweep.
335 $current_storage = array();
336 foreach ( $map as $field => $config ) {
337 $current_storage[ $field ] = self::read_option( $config );
338 }
339
340 $changed = array();
341 foreach ( $normalized as $field => $desired ) {
342 // String-cast on both sides because every field's storage form is
343 // scalar (`0`/`1` for BOOL, `'on'`/`'off'` for ON_OFF, plain strings
344 // for ENUM/STRING). New field types added later must keep that
345 // invariant or this comparison will misfire.
346 if ( (string) $desired === (string) $current_storage[ $field ] ) {
347 continue;
348 }
349 update_option( $map[ $field ]['option'], $desired );
350 $current_storage[ $field ] = $desired;
351 $changed[] = $field;
352 }
353
354 $settings = array();
355 foreach ( $map as $field => $config ) {
356 $settings[ $field ] = self::cast_to_response( $config, $current_storage[ $field ] );
357 }
358
359 return array(
360 'settings' => $settings,
361 'changed' => $changed,
362 );
363 }
364
365 /**
366 * Map of public ability field name → backing option config.
367 *
368 * Storage shape (option key, type tag, default, enum). The agent-facing
369 * descriptions and JSON Schema live in `get_abilities()`; this map drives
370 * the storage-side validation, normalization, and casting.
371 *
372 * Kept as a method (not a class constant) so the description strings
373 * referenced from `cast_to_response()` and `normalize_input_value()` can
374 * resolve through `__()` at call time rather than file load time.
375 */
376 private static function settings_map(): array {
377 return array(
378 'subscribe_post_end_enabled' => array(
379 'option' => 'stb_enabled',
380 'type' => self::TYPE_BOOL,
381 'default' => 1,
382 ),
383 'subscribe_comments_enabled' => array(
384 'option' => 'stc_enabled',
385 'type' => self::TYPE_BOOL,
386 'default' => 1,
387 ),
388 'notify_admin_on_subscribe' => array(
389 'option' => 'social_notifications_subscribe',
390 'type' => self::TYPE_ON_OFF,
391 'default' => 'on',
392 ),
393 'reply_to' => array(
394 'option' => 'jetpack_subscriptions_reply_to',
395 'type' => self::TYPE_ENUM,
396 'default' => Subscriptions_Settings::$default_reply_to,
397 'enum' => self::REPLY_TO_VALUES,
398 ),
399 'from_name' => array(
400 'option' => 'jetpack_subscriptions_from_name',
401 'type' => self::TYPE_STRING,
402 'default' => '',
403 'max_length' => 200,
404 ),
405 );
406 }
407
408 /**
409 * Read all settings as the public response shape.
410 */
411 private static function current_settings(): array {
412 $out = array();
413 foreach ( self::settings_map() as $field => $config ) {
414 $out[ $field ] = self::cast_to_response( $config, self::read_option( $config ) );
415 }
416 return $out;
417 }
418
419 /**
420 * Read the raw option for a field config, falling back to its default.
421 *
422 * @param array $config Field config from `settings_map()`.
423 * @return mixed
424 */
425 private static function read_option( array $config ) {
426 return get_option( $config['option'], $config['default'] );
427 }
428
429 /**
430 * Validate + normalize a single input value to the storage form.
431 *
432 * @param string $field Public field name (used in error messages).
433 * @param array $config Field config from `settings_map()`.
434 * @param mixed $value Raw input value.
435 * @return mixed|\WP_Error Storage-form value, or WP_Error when invalid.
436 */
437 private static function normalize_input_value( string $field, array $config, $value ) {
438 switch ( $config['type'] ) {
439 case self::TYPE_BOOL:
440 if ( ! is_bool( $value ) ) {
441 return self::invalid_field( $field, __( 'expected a boolean (true or false).', 'jetpack' ) );
442 }
443 return $value ? 1 : 0;
444
445 case self::TYPE_ON_OFF:
446 if ( ! is_bool( $value ) ) {
447 return self::invalid_field( $field, __( 'expected a boolean (true or false).', 'jetpack' ) );
448 }
449 return $value ? 'on' : 'off';
450
451 case self::TYPE_ENUM:
452 // reply_to is the only enum today and shares its allowed-values
453 // list with `Subscriptions_Settings::is_valid_reply_to()`. Defer
454 // to that validator so the two surfaces can't drift.
455 $valid = 'reply_to' === $field
456 ? Subscriptions_Settings::is_valid_reply_to( $value )
457 : ( is_string( $value ) && in_array( $value, $config['enum'], true ) );
458 if ( ! $valid ) {
459 return self::invalid_field(
460 $field,
461 sprintf(
462 /* translators: %s: comma-separated list of allowed values. */
463 __( 'allowed values are %s.', 'jetpack' ),
464 implode( ', ', $config['enum'] )
465 )
466 );
467 }
468 return $value;
469
470 case self::TYPE_STRING:
471 if ( ! is_string( $value ) ) {
472 return self::invalid_field( $field, __( 'expected a string.', 'jetpack' ) );
473 }
474 $sanitized = sanitize_text_field( $value );
475 if ( isset( $config['max_length'] ) && mb_strlen( $sanitized ) > (int) $config['max_length'] ) {
476 return self::invalid_field(
477 $field,
478 sprintf(
479 /* translators: %d: maximum number of characters. */
480 __( 'must be %d characters or fewer.', 'jetpack' ),
481 (int) $config['max_length']
482 )
483 );
484 }
485 return $sanitized;
486 }
487
488 return self::invalid_field( $field, __( 'unsupported field type.', 'jetpack' ) );
489 }
490
491 /**
492 * Cast a stored option value to the public response shape.
493 *
494 * @param array $config Field config from `settings_map()`.
495 * @param mixed $value Raw stored value.
496 * @return mixed
497 */
498 private static function cast_to_response( array $config, $value ) {
499 switch ( $config['type'] ) {
500 case self::TYPE_BOOL:
501 return 1 === (int) $value;
502 case self::TYPE_ON_OFF:
503 return 'on' === (string) $value;
504 case self::TYPE_ENUM:
505 $value = (string) $value;
506 return in_array( $value, $config['enum'], true ) ? $value : (string) $config['default'];
507 case self::TYPE_STRING:
508 return (string) $value;
509 }
510 return $value;
511 }
512
513 /**
514 * Build a `jetpack_newsletter_invalid_<field>` WP_Error with a message
515 * that names the field and tells the agent how to fix the input.
516 *
517 * @param string $field Public field name; appears in the error code and message.
518 * @param string $reason Translated explanation of the expected value.
519 * @return \WP_Error
520 */
521 private static function invalid_field( string $field, string $reason ): \WP_Error {
522 return new \WP_Error(
523 'jetpack_newsletter_invalid_' . $field,
524 sprintf(
525 /* translators: 1: field name, 2: explanation of the expected value. */
526 __( 'Invalid value for "%1$s": %2$s', 'jetpack' ),
527 $field,
528 $reason
529 )
530 );
531 }
532 }
533