Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
42.69% covered (danger)
42.69%
73 / 171
7.69% covered (danger)
7.69%
1 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Holiday_Snow
42.69% covered (danger)
42.69%
73 / 171
7.69% covered (danger)
7.69%
1 / 13
426.17
0.00% covered (danger)
0.00%
0 / 1
 get_config
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 get_hemisphere_setting
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 is_snow_season
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
8.05
 is_snow_enabled
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 init
84.78% covered (warning)
84.78%
39 / 46
0.00% covered (danger)
0.00%
0 / 1
3.03
 holiday_snow_markup
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
5.93
 holiday_snow_script
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 add_option_api
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 update_option_api
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 register_settings
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
56
 holiday_snow_option_updated
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 sanitize_option
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 sanitize_option_within_int_range
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Holiday Snow
4 * Adds falling snow to a blog for a season.
5 *
6 * @since 6.1.0
7 *
8 * @package automattic/jetpack-mu-wpcom
9 */
10
11namespace Automattic\Jetpack\Jetpack_Mu_Wpcom;
12
13/**
14 * Holiday Snow (admin and frontend).
15 */
16class Holiday_Snow {
17    /**
18     * Option names.
19     */
20    private const OPTION_ENABLED    = 'jetpack_holiday_snow_enabled';
21    private const OPTION_GRID_WIDTH = 'jetpack_holiday_snow_grid_width';
22    private const OPTION_DENSITY    = 'jetpack_holiday_snow_density';
23    private const OPTION_SPEED      = 'jetpack_holiday_snow_speed';
24
25    /**
26     * Settings config; defined in init().
27     *
28     * @var array<string, array>
29     */
30    private static $holiday_snow_config = array();
31
32    /**
33     * Cached value for snow enabled option.
34     *
35     * @var bool|null
36     */
37    private static $is_snow_enabled_cache = null;
38
39    /**
40     * Get a config array safely, ensuring config is initialized.
41     *
42     * @since 6.9.0
43     *
44     * @param string $option_name The option name.
45     * @return array|null The config array or null if not initialized.
46     */
47    private static function get_config( $option_name ) {
48        // Ensure config is initialized.
49        if ( empty( self::$holiday_snow_config ) ) {
50            return null;
51        }
52
53        if ( ! isset( self::$holiday_snow_config[ $option_name ] ) ) {
54            return null;
55        }
56
57        return self::$holiday_snow_config[ $option_name ];
58    }
59
60    /**
61     * Get the hemisphere setting, automatically detected from timezone.
62     * Attempts to detect hemisphere from the site's timezone location data.
63     * Falls back to Northern hemisphere if detection is not possible
64     * e.g., UTC or offset-based timezones).
65     *
66     * @since 6.9.0
67     *
68     * @return string 'south' or 'north'.
69     */
70    private static function get_hemisphere_setting() {
71        $hemisphere = 'north';
72        $timezone   = wp_timezone();
73
74        /*
75         * Get location data from the timezone object.
76         * This only works for named timezones (e.g., "Europe/Budapest"), not offsets.
77         */
78        $location = $timezone->getLocation();
79
80        if ( $location && isset( $location['latitude'] ) ) {
81            $latitude = (float) $location['latitude'];
82
83            /*
84             * Latitude > 0 = Northern hemisphere, < 0 = Southern hemisphere.
85             */
86            if ( $latitude < 0 ) {
87                $hemisphere = 'south';
88            }
89        }
90
91        /**
92         * Filter the detected hemisphere setting.
93         * Allows overriding the automatic hemisphere detection from timezone.
94         *
95         * @since 6.9.0
96         *
97         * @param string $hemisphere The detected hemisphere. Either 'north' or 'south'.
98         */
99        $hemisphere = apply_filters( 'jetpack_holiday_snow_hemisphere', $hemisphere );
100
101        if ( ! in_array( $hemisphere, array( 'north', 'south' ), true ) ) {
102            $hemisphere = 'north';
103        }
104
105        return $hemisphere;
106    }
107
108    /**
109     * Check if it is the holiday snow season, based on hemisphere and date.
110     *
111     * The hemisphere is automatically detected from the site's timezone.
112     * If detection is not possible, fallback to Northern hemisphere.
113     * Northern hemisphere: shows from 1 December through 6 January.
114     * Southern hemisphere: shows from 1 June through 6 July.
115     *
116     * @return bool
117     */
118    public static function is_snow_season() {
119        $is_snow_season = false;
120        $today          = time();
121
122        $hemisphere = self::get_hemisphere_setting();
123        if ( 'south' === $hemisphere ) {
124            $first_snow_day = mktime( 0, 0, 0, 6, 1 );
125            $last_snow_day  = mktime( 0, 0, 0, 7, 7 );
126            // Southern hemisphere: June 1 - July 7 (doesn't span year boundary, use AND)
127            if ( $today >= $first_snow_day && $today < $last_snow_day ) {
128                $is_snow_season = true;
129            }
130        } else {
131            $first_snow_day = mktime( 0, 0, 0, 12, 1 );
132            $last_snow_day  = mktime( 0, 0, 0, 1, 7 );
133            // Northern hemisphere: Dec 1 - Jan 7 (spans year boundary, use OR)
134            if ( $today >= $first_snow_day || $today < $last_snow_day ) {
135                $is_snow_season = true;
136            }
137        }
138
139        /**
140         * Filter to check if it is the snow season.
141         * It allows to change the start and end dates of the season,
142         * for regions where the holiday season may be different.
143         *
144         * @since 6.1.0
145         *
146         * @param bool $is_holiday_snow_season Is it the snow season?
147         */
148        return apply_filters( 'jetpack_is_holiday_snow_season', $is_snow_season );
149    }
150
151    /**
152     * Check if the snow is enabled.
153     *
154     * @return bool
155     */
156    public static function is_snow_enabled() {
157        if ( null === self::$is_snow_enabled_cache ) {
158            self::$is_snow_enabled_cache = (bool) get_option( self::OPTION_ENABLED );
159        }
160        return self::$is_snow_enabled_cache;
161    }
162
163    /**
164     * Register the hooks.
165     *
166     * @return void
167     */
168    public static function init() {
169        self::$holiday_snow_config = array(
170            self::OPTION_ENABLED    => array(
171                'default'     => false,
172                'type'        => 'boolean',
173                'description' => __( 'Show falling snow on my site.', 'jetpack-mu-wpcom' ),
174                'label'       => __( 'Enable Holiday Snow', 'jetpack-mu-wpcom' ),
175            ),
176            self::OPTION_GRID_WIDTH => array(
177                'default'     => 600,
178                'min'         => 100,
179                'max'         => 1000,
180                'step'        => 10,
181                'type'        => 'integer',
182                'description' => __( 'How wide a grid of snow is.', 'jetpack-mu-wpcom' ),
183                'label'       => __( 'Snow Grid Width', 'jetpack-mu-wpcom' ),
184                'hidden'      => true, // Disabled for now, as it's used in a SCSS for loop
185            ),
186            self::OPTION_DENSITY    => array(
187                'default'     => 10,
188                'min'         => 1,
189                'max'         => 30,
190                'step'        => 1,
191                'type'        => 'integer',
192                'description' => __( 'How many snowflakes appear on the screen at a given time.', 'jetpack-mu-wpcom' ),
193                'label'       => __( 'Snow Density', 'jetpack-mu-wpcom' ),
194                'hidden'      => true, // Disabled for now, as it's used in a SCSS for loop
195            ),
196            self::OPTION_SPEED      => array(
197                'default'     => 9,
198                'min'         => 1,
199                'max'         => 20,
200                'step'        => 1,
201                'type'        => 'integer',
202                'description' => __( 'How long it takes for a snowflake to get to the bottom of the screen. The lower the number, the faster it goes.', 'jetpack-mu-wpcom' ),
203                'label'       => __( 'Snow Speed', 'jetpack-mu-wpcom' ),
204            ),
205        );
206
207        // Only show settings if it's snow season.
208        if ( ! self::is_snow_season() ) {
209            return;
210        }
211
212        add_filter( 'site_settings_endpoint_get', array( __CLASS__, 'add_option_api' ) );
213        add_filter( 'rest_api_update_site_settings', array( __CLASS__, 'update_option_api' ), 10, 2 );
214        add_action( 'update_option_' . self::OPTION_ENABLED, array( __CLASS__, 'holiday_snow_option_updated' ) );
215        add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
216
217        if ( self::is_snow_enabled() ) {
218            add_action( 'wp_footer', array( __CLASS__, 'holiday_snow_markup' ) );
219            add_action( 'wp_enqueue_scripts', array( __CLASS__, 'holiday_snow_script' ) );
220        }
221    }
222
223    /**
224     * Add the snowstorm markup to the footer.
225     *
226     * @return void
227     * @since 6.1.0
228     */
229    public static function holiday_snow_markup() {
230        // Get the snow speed option, fallback to default if not set.
231        // Use hardcoded default (9) if config is not initialized yet.
232        $speed_config  = self::get_config( self::OPTION_SPEED );
233        $default_speed = $speed_config ? $speed_config['default'] : 9;
234        $snow_speed    = get_option( self::OPTION_SPEED, $default_speed );
235
236        // Sanitize the value, using config if available, otherwise use hardcoded defaults.
237        if ( $speed_config ) {
238            $snow_speed = self::sanitize_option( $snow_speed, $speed_config );
239        } else {
240            // Fallback sanitization if config not initialized: ensure it's between 1-20, default to 9.
241            $snow_speed = (int) $snow_speed;
242            if ( $snow_speed < 1 || $snow_speed > 20 ) {
243                $snow_speed = 9;
244            }
245        }
246
247        echo '<div id="jetpack-holiday-snow" style="--jetpack-holiday-snow-speed: ' . (int) $snow_speed . 's;" ></div>';
248    }
249
250    /**
251     * Enqueue the snowstorm CSS on the frontend.
252     *
253     * @return void
254     */
255    public static function holiday_snow_script() {
256        if (
257            /**
258             * Allow short-circuiting the snow, even when enabled on the site in settings.
259             *
260             * @since 6.1.0
261             *
262             * @param bool true Whether to show the snow.
263             */
264            ! apply_filters( 'jetpack_holiday_chance_of_snow', true )
265        ) {
266            return;
267        }
268
269        /**
270         * Fires when the snow is falling.
271         *
272         * @since 6.1.0
273         */
274        do_action( 'jetpack_stats_extra', 'holiday_snow', 'snowing' );
275
276        wp_enqueue_style(
277            'holiday-snow',
278            plugins_url( 'build/holiday-snow/holiday-snow.css', \Automattic\Jetpack\Jetpack_Mu_Wpcom::BASE_FILE ),
279            array(),
280            \Automattic\Jetpack\Jetpack_Mu_Wpcom::PACKAGE_VERSION
281        );
282    }
283
284    /**
285     * Add the option to the v1 API site settings endpoint.
286     *
287     * @param array $settings A single site's settings.
288     * @return array
289     */
290    public static function add_option_api( $settings ) {
291        foreach ( self::$holiday_snow_config as $option_name => $option_config ) {
292            $value                    = get_option( $option_name, $option_config['default'] );
293            $settings[ $option_name ] = self::sanitize_option( $value, $option_config );
294        }
295        return $settings;
296    }
297
298    /**
299     * Update settings via public-api.wordpress.com.
300     *
301     * @param array $input             Associative array of site settings to be updated.
302     *                                 Cast and filtered based on documentation.
303     * @param array $unfiltered_input  Associative array of site settings to be updated.
304     *                                 Neither cast nor filtered. Contains raw input.
305     * @return array
306     */
307    public static function update_option_api( $input, $unfiltered_input ) {
308        foreach ( self::$holiday_snow_config as $option_name => $option_config ) {
309            if ( isset( $unfiltered_input[ $option_name ] ) ) {
310                $input[ $option_name ] = self::sanitize_option( $unfiltered_input[ $option_name ], $option_config );
311            }
312        }
313        return $input;
314    }
315
316    /**
317     * Registers the settings section and fields.
318     *
319     * @return void
320     */
321    public static function register_settings() {
322        foreach ( self::$holiday_snow_config as $option_name => $option_config ) {
323            if ( 'boolean' === $option_config['type'] ) {
324                $sanitize_callback = 'boolval';
325            } elseif ( 'integer' === $option_config['type'] ) {
326                $sanitize_callback = function ( $value ) use ( $option_config ) {
327                    return self::sanitize_option_within_int_range( $value, $option_config );
328                };
329            } else {
330                // This shouldn't ever happen, but let's be careful anyway.
331                continue;
332            }
333            register_setting(
334                'general',
335                $option_name,
336                array(
337                    'type'              => $option_config['type'],
338                    'description'       => esc_attr( $option_config['description'] ),
339                    'show_in_rest'      => true,
340                    'default'           => $option_config['default'],
341                    'sanitize_callback' => $sanitize_callback,
342                )
343            );
344
345            // Hide settings as desired.
346            if ( ! empty( $option_config['hidden'] ) ) {
347                continue;
348            }
349
350            add_settings_field(
351                $option_name,
352                esc_attr( $option_config['label'] ),
353                function () use ( $option_name, $option_config ) {
354                    $value = get_option( $option_name, $option_config['default'] );
355                    if ( 'boolean' === $option_config['type'] ) {
356                        printf(
357                            '<input type="checkbox" name="%1$s" id="%1$s" value="1" %2$s /><label for="%1$s">%3$s</label>',
358                            esc_attr( $option_name ),
359                            checked( $value, true, false ),
360                            esc_html( $option_config['description'] )
361                        );
362                    } elseif ( 'integer' === $option_config['type'] ) {
363                        printf(
364                            '<input type="number" name="%1$s" id="%1$s" value="%2$d" min="%3$d" max="%4$d" step="%5$d" />',
365                            esc_attr( $option_name ),
366                            (int) $value,
367                            (int) $option_config['min'],
368                            (int) $option_config['max'],
369                            (int) $option_config['step']
370                        );
371                        printf(
372                            '<p>%s</p>',
373                            esc_html( $option_config['description'] )
374                        );
375                        printf(
376                            '<p>%s</p>',
377                            // translators: %s is the default snow speed value.
378                            esc_html( sprintf( __( 'Default: %s', 'jetpack-mu-wpcom' ), $option_config['default'] ) )
379                        );
380                    }
381                },
382                'general',
383                'default',
384                array(
385                    'label_for' => $option_name,
386                )
387            );
388        }
389    }
390
391    /**
392     * Fires whenever the holiday snow option is updated.
393     * Used to gather stats about modified options.
394     *
395     * @return void
396     */
397    public static function holiday_snow_option_updated() {
398        /** This action is already documented in modules/widgets/gravatar-profile.php */
399        do_action( 'jetpack_stats_extra', 'holiday_snow', 'toggle' );
400    }
401
402    /**
403     * Sanitize a single option value using config.
404     *
405     * @param mixed $value   The option value to sanitize.
406     * @param array $config  Option config array.
407     * @return bool|int|null Sanitized value, or null if an unknown type.
408     */
409    public static function sanitize_option( $value, $config ) {
410        if ( 'boolean' === $config['type'] ) {
411            return (bool) $value;
412        } elseif ( 'integer' === $config['type'] ) {
413            return self::sanitize_option_within_int_range( $value, $config );
414        }
415        // this shouldn't ever happen, but just in case...
416        return null;
417    }
418
419    /**
420     * Sanitize a value to be within a given min/max range, falling back to default as needed.
421     * Assumes 'min', 'max', and 'default' always exist in $config.
422     *
423     * @param mixed $value  The value to sanitize.
424     * @param array $config Option config array.
425     * @return int          The sanitized value, or default if out of range.
426     */
427    public static function sanitize_option_within_int_range( $value, $config ) {
428        $value = (int) $value;
429        if ( $value < $config['min'] || $value > $config['max'] ) {
430            return $config['default'];
431        }
432        return $value;
433    }
434}