! empty( $raw['enabled'] ) && is_scalar( $raw['enabled'] ), 'frequency' => isset( $raw['frequency'] ) && in_array( $raw['frequency'], self::INTERVALS, true ) ? $raw['frequency'] : self::MONTHLY, 'weekday' => isset( $raw['weekday'] ) && is_scalar( $raw['weekday'] ) ? min( 7, max( 1, (int) $raw['weekday'] ) ) : 1, 'monthday' => isset( $raw['monthday'] ) && is_scalar( $raw['monthday'] ) ? min( self::MAX_MONTH_DAY, max( self::MIN_MONTH_DAY, (int) $raw['monthday'] ) ) : self::MIN_MONTH_DAY, 'hour' => isset( $raw['hour'] ) && is_scalar( $raw['hour'] ) ? min( 23, max( 0, (int) $raw['hour'] ) ) : 8, 'to_viewers' => ! empty( $raw['to_viewers'] ) && is_scalar( $raw['to_viewers'] ), 'user_ids' => $user_ids, 'recipients' => $recipients, // Scheduled reports compare with the period just before them. 'compare' => 'previous', 'note' => isset( $raw['note'] ) && is_scalar( $raw['note'] ) ? Text::substr( sanitize_textarea_field( (string) $raw['note'] ), 0, 1000 ) : '', ); } /** * Whether a site's schedule is on and has an audience. Who that resolves to * is decided at send time by Recipients. * * @param array $email Normalised settings. * @return bool */ public static function is_active( array $email ) { return ! empty( $email['enabled'] ) && self::has_audience( $email ); } /** * Whether any audience is chosen: viewers, specific users or addresses. * * @param array $email Normalised settings. * @return bool */ public static function has_audience( array $email ) { return ! empty( $email['to_viewers'] ) || ! empty( $email['user_ids'] ) || ! empty( $email['recipients'] ); } /** * The period an email covers: the last 7 or 14 settled days, the last * full month or the last full quarter, with the chosen comparison. * * Scheduled sends pass the date of their slot as $today, so a late or * retried send still covers the period it was due for. * * @param array $email Normalised settings. * @param string|null $today Override for "today" (Y-m-d, site time zone). * @return array{s:string,e:string,ps:string,pe:string} */ public static function period( array $email, $today = null ) { switch ( $email['frequency'] ) { case self::WEEKLY: $period = Periods::last_days( 7, $today ); break; case self::BIWEEKLY: $period = Periods::last_days( 14, $today ); break; case self::QUARTERLY: $period = Periods::last_full_quarter( $today ); break; default: $period = Periods::last_full_month( $today ); } return 'year' === $email['compare'] ? Periods::versus_last_year( $period ) : $period; } /** * Whether an interval is set by weekday (weekly, every 2 weeks) rather than day of the month. * * @param string $frequency Interval. * @return bool */ public static function by_weekday( $frequency ) { return self::WEEKLY === $frequency || self::BIWEEKLY === $frequency; } /** * Human labels for the intervals, with what each email covers. * * @return array */ public static function interval_labels() { return array( self::WEEKLY => __( 'Every week (covers the last 7 days)', 'turtlereport' ), self::BIWEEKLY => __( 'Every 2 weeks (covers the last 14 days)', 'turtlereport' ), self::MONTHLY => __( 'Every month (covers last month)', 'turtlereport' ), self::QUARTERLY => __( 'Every 3 months (covers the last full quarter)', 'turtlereport' ), ); } /** * Short label for lists, e.g. "Monthly". * * @param string $frequency Interval. * @return string */ public static function short_label( $frequency ) { $labels = array( self::WEEKLY => __( 'Weekly', 'turtlereport' ), self::BIWEEKLY => __( 'Every 2 weeks', 'turtlereport' ), self::MONTHLY => __( 'Monthly', 'turtlereport' ), self::QUARTERLY => __( 'Quarterly', 'turtlereport' ), ); return isset( $labels[ $frequency ] ) ? $labels[ $frequency ] : $labels[ self::MONTHLY ]; } /** * Roughly how long one interval lasts. * * @param string $frequency Interval. * @return int Seconds. */ public static function interval_seconds( $frequency ) { switch ( $frequency ) { case self::WEEKLY: return 7 * DAY_IN_SECONDS; case self::BIWEEKLY: return 14 * DAY_IN_SECONDS; case self::QUARTERLY: return 92 * DAY_IN_SECONDS; default: return 31 * DAY_IN_SECONDS; } } /** * The slot a late send stands for. * * Normally that's the slot that was due. When cron didn't run for longer * than a whole interval (the site was down or had no visits), the missed * periods aren't sent one after another: only the most recent slot is, * so the client gets one up-to-date report instead of a pile of old ones. * * @param array $email Normalised settings. * @param int $slot The slot that was due. * @param int $now Unix time. * @return int Unix time of the slot to send for. */ public static function latest_slot( array $email, $slot, $now ) { $slot = (int) $slot; $now = (int) $now; if ( $slot <= 0 || $now - $slot <= self::interval_seconds( $email['frequency'] ) ) { return $slot > 0 ? $slot : $now; } $t = self::next_run( $email, $now - 2 * self::interval_seconds( $email['frequency'] ) ); $guard = 0; while ( $t <= $now && $guard++ < 10 ) { $n = self::next_run( $email, $t ); if ( $n > $now ) { break; } $t = $n; } return $t <= $now ? $t : $slot; } /** * Next send time after a moment. * * Weekly: the next chosen weekday at the chosen hour. Every 2 weeks: the * same, but at least 13 days after the last slot. Monthly: the chosen day * of the month. Quarterly: the chosen day of January, April, July or October, * so each email covers a whole calendar quarter. * * @param array $email Normalised settings. * @param int $after Unix timestamp. * @param int $anchor The slot of the last send (when it was due, not when it went out), 0 if never. * @return int Unix timestamp. */ public static function next_run( array $email, $after, $anchor = 0 ) { $tz = wp_timezone(); $now = ( new \DateTimeImmutable( '@' . (int) $after ) )->setTimezone( $tz ); if ( self::by_weekday( $email['frequency'] ) ) { $from = $now; if ( self::BIWEEKLY === $email['frequency'] && $anchor ) { $earliest = ( new \DateTimeImmutable( '@' . ( (int) $anchor + 13 * DAY_IN_SECONDS ) ) )->setTimezone( $tz ); $from = $earliest > $now ? $earliest : $now; } $diff = ( (int) $email['weekday'] - (int) $from->format( 'N' ) + 7 ) % 7; // Move the date before setting the hour: on a DST change day the hour may not exist. $next = $from->modify( '+' . $diff . ' days' )->setTime( (int) $email['hour'], 0 ); if ( $next <= $from ) { $next = $next->modify( '+7 days' )->setTime( (int) $email['hour'], 0 ); } return $next->getTimestamp(); } $step = self::QUARTERLY === $email['frequency'] ? 3 : 1; $month = (int) $now->format( 'n' ); if ( 3 === $step ) { // Move to the first month of the current quarter (1, 4, 7, 10). $month = (int) ( floor( ( $month - 1 ) / 3 ) * 3 + 1 ); } $next = $now->setDate( (int) $now->format( 'Y' ), $month, (int) $email['monthday'] )->setTime( (int) $email['hour'], 0 ); while ( $next <= $now ) { $first = $next->setDate( (int) $next->format( 'Y' ), (int) $next->format( 'n' ), 1 )->modify( '+' . $step . ' months' ); $next = $first->setDate( (int) $first->format( 'Y' ), (int) $first->format( 'n' ), (int) $email['monthday'] )->setTime( (int) $email['hour'], 0 ); } return $next->getTimestamp(); } }