| @@ -1,18 +1,19 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | 3 | * Email Report Config |
| 4 | 4 | * |
| 5 | - * Persistence layer for the per-site Email Reporting settings. Stores a | |
| 6 | - * single associative array under the `thinkrank_email_report_config` option | |
| 7 | - * — one row per site is enough; we don't shard by user. Values are | |
| 8 | - * sanitized at the boundary and capability-clamped via Plan_Config so a | |
| 9 | - * free plan never accidentally persists Pro values that don't belong. | |
| 5 | + * Persistence layer for the per-site Email Reporting state. Stores a single | |
| 6 | + * associative array under the `thinkrank_email_report_config` option. | |
| 10 | 7 | * |
| 11 | - * Pro plugin can extend the saved schema by hooking | |
| 12 | - * `thinkrank_email_report_config_schema` (added fields are sanitized | |
| 13 | - * if a callback is provided). | |
| 8 | + * The free report is fixed: every 30 days, to the site admin email, with every | |
| 9 | + * section. What is stored is only whether it is on and when it last and next | |
| 10 | + * runs. ThinkRank Pro owns the schedule, recipient and branding settings and | |
| 11 | + * supplies them through the `thinkrank_email_report_config` filter (#673). | |
| 14 | 12 | * |
| 13 | + * Keys a save does not own are left in the stored array untouched, so values an | |
| 14 | + * earlier release wrote there (recipients, branding) survive for Pro to pick up. | |
| 15 | + * | |
| 15 | 16 | * @package ThinkRank |
| 16 | 17 | * @subpackage SEO |
| 17 | 18 | * @since 1.9.0 |
| 18 | 19 | */ |
| @@ -20,10 +21,8 @@ | ||
| 20 | 21 | declare(strict_types=1); |
| 21 | 22 | |
| 22 | 23 | namespace ThinkRank\SEO; |
| 23 | 24 | |
| 24 | -use ThinkRank\Core\Plan_Config; | |
| 25 | - | |
| 26 | 25 | if (!defined('ABSPATH')) { |
| 27 | 26 | exit; |
| 28 | 27 | } |
| 29 | 28 | |
| @@ -36,264 +35,206 @@ | ||
| 36 | 35 | |
| 37 | 36 | private const OPTION_KEY = 'thinkrank_email_report_config'; |
| 38 | 37 | |
| 39 | 38 | /** |
| 40 | - * Load defaults helper. Lazy-loads the config defaults file because | |
| 41 | - * it lives outside the autoloader path (it's procedural functions). | |
| 39 | + * Days between reports when nothing filters the schedule. | |
| 42 | 40 | */ |
| 43 | - private function defaults(): array { | |
| 41 | + public const FREQUENCY_DAYS = 30; | |
| 42 | + | |
| 43 | + /** | |
| 44 | + * Load the procedural defaults file, which lives outside the autoloader. | |
| 45 | + */ | |
| 46 | + private function load_defaults_file(): void { | |
| 44 | 47 | if (!function_exists('thinkrank_get_default_email_report_config')) { |
| 45 | 48 | require_once THINKRANK_PLUGIN_DIR . 'includes/config/email-report-settings-config.php'; |
| 46 | 49 | } |
| 47 | - return thinkrank_get_default_email_report_config(); | |
| 48 | 50 | } |
| 49 | 51 | |
| 50 | 52 | /** |
| 51 | - * Read the current config. Always merges over defaults so newly added | |
| 52 | - * keys (e.g. after a plugin update) are populated even on existing sites. | |
| 53 | + * The stored option, as an array. | |
| 53 | 54 | */ |
| 54 | - public function get(): array { | |
| 55 | + private function stored(): array { | |
| 55 | 56 | $stored = get_option(self::OPTION_KEY, []); |
| 56 | - if (!is_array($stored)) { | |
| 57 | - $stored = []; | |
| 58 | - } | |
| 59 | - return array_merge($this->defaults(), $stored); | |
| 57 | + | |
| 58 | + return is_array($stored) ? $stored : []; | |
| 60 | 59 | } |
| 61 | 60 | |
| 62 | 61 | /** |
| 63 | - * Save the config. Returns the post-sanitize array that was persisted | |
| 64 | - * so callers can echo it back to the client and avoid a second read. | |
| 62 | + * The resolved config every consumer reads. | |
| 65 | 63 | * |
| 66 | - * Sanitization happens here, not in the REST args layer — the REST | |
| 67 | - * layer accepts intent, this layer enforces invariants. That way the | |
| 68 | - * cron-driven path (which doesn't go through REST) gets the same guarantees. | |
| 64 | + * @return array{enabled: bool, frequency_days: int, recipients: string[], sections_enabled: string[], next_scheduled_at: ?string, last_sent_at: ?string, last_skip: ?array} | |
| 69 | 65 | */ |
| 70 | - public function save(array $input): array { | |
| 71 | - $sanitized = $this->sanitize($input); | |
| 66 | + public function get(): array { | |
| 67 | + $this->load_defaults_file(); | |
| 72 | 68 | |
| 73 | - // Defense-in-depth: re-clamp at save time even though sanitize() also clamps. | |
| 74 | - $sanitized['frequency_days'] = Plan_Config::clamp_email_report_frequency((int) $sanitized['frequency_days']); | |
| 75 | - $sanitized['recipients'] = Plan_Config::clamp_email_report_recipients($sanitized['recipients']); | |
| 69 | + $stored = $this->stored(); | |
| 70 | + $state = [ | |
| 71 | + 'enabled' => !empty($stored['enabled']), | |
| 72 | + 'next_scheduled_at' => $stored['next_scheduled_at'] ?? null, | |
| 73 | + 'last_sent_at' => $stored['last_sent_at'] ?? null, | |
| 74 | + // Why the last scheduled run sent nothing, or null. Read by the | |
| 75 | + // panel so a paused report is never mistaken for a healthy one. | |
| 76 | + 'last_skip' => is_array($stored['last_skip'] ?? null) ? $stored['last_skip'] : null, | |
| 77 | + ]; | |
| 76 | 78 | |
| 77 | - $previous = get_option(self::OPTION_KEY, []); | |
| 78 | - $previous = is_array($previous) ? $previous : []; | |
| 79 | - $frequency_changed = isset($previous['frequency_days']) | |
| 80 | - && (int) $previous['frequency_days'] !== (int) $sanitized['frequency_days']; | |
| 79 | + $report = [ | |
| 80 | + 'frequency_days' => self::FREQUENCY_DAYS, | |
| 81 | + 'recipients' => [(string) get_option('admin_email')], | |
| 82 | + 'sections_enabled' => array_keys(thinkrank_get_email_report_default_sections()), | |
| 83 | + ]; | |
| 81 | 84 | |
| 82 | - // Seed next_scheduled_at on first enable so the UI shows a real | |
| 83 | - // "Next report" date immediately. The scheduler still re-seeds on | |
| 84 | - // its first tick for any other path that flips enable on. | |
| 85 | - // | |
| 86 | - // A frequency change also has to move the date: carrying the old | |
| 87 | - // timestamp through meant a user switching 30 → 7 days still waited | |
| 88 | - // out the original 30-day window before the new cadence took effect. | |
| 89 | - if ($sanitized['enabled'] && (empty($sanitized['next_scheduled_at']) || $frequency_changed)) { | |
| 90 | - // Anchor off the last send when we have one, so shortening the | |
| 91 | - // cadence brings the next report forward instead of adding a | |
| 92 | - // fresh full period on top of time already elapsed. | |
| 93 | - // last_sent_at is a site-local wall clock (current_time('mysql')). | |
| 94 | - // strtotime() would read it as UTC and skew the whole cadence by | |
| 95 | - // the site's offset, so resolve it in the site timezone instead. | |
| 96 | - $anchor = $frequency_changed && !empty($sanitized['last_sent_at']) | |
| 97 | - ? (int) get_gmt_from_date((string) $sanitized['last_sent_at'], 'U') | |
| 98 | - : time(); | |
| 99 | - $anchor = $anchor ?: time(); | |
| 100 | - | |
| 101 | - $next = strtotime('+' . max(1, (int) $sanitized['frequency_days']) . ' days', $anchor); | |
| 102 | - | |
| 103 | - // Never schedule into the past — a big cadence cut on an old | |
| 104 | - // last_sent_at means "due now", which the next tick picks up. | |
| 105 | - $sanitized['next_scheduled_at'] = wp_date( | |
| 106 | - 'Y-m-d H:i:s', | |
| 107 | - max($next ?: time(), time()) | |
| 108 | - ); | |
| 109 | - } | |
| 110 | - | |
| 111 | - update_option(self::OPTION_KEY, $sanitized, false); | |
| 112 | - | |
| 113 | 85 | /** |
| 114 | - * Fires after Email Report config is saved. | |
| 86 | + * Filter what the report covers and who receives it. | |
| 115 | 87 | * |
| 116 | - * Pro plugin uses this to re-validate its own added fields, refresh | |
| 117 | - * an audit table, or trigger a re-schedule. | |
| 88 | + * ThinkRank Pro returns its own schedule, recipients and sections here. | |
| 89 | + * Extra keys are passed through to the renderer and mailer filters. | |
| 90 | + * The on/off switch and schedule timestamps are not filterable. | |
| 118 | 91 | * |
| 119 | - * @since 1.9.0 | |
| 92 | + * @since 2.6.0 | |
| 120 | 93 | * |
| 121 | - * @param array $sanitized The persisted config. | |
| 94 | + * @param array $report { | |
| 95 | + * @type int $frequency_days Days between reports. | |
| 96 | + * @type string[] $recipients Recipient addresses. | |
| 97 | + * @type string[] $sections_enabled Section keys, in render order. | |
| 98 | + * } | |
| 99 | + * @param array $state Stored on/off switch and schedule timestamps. | |
| 122 | 100 | */ |
| 123 | - do_action('thinkrank_email_report_settings_saved', $sanitized); | |
| 101 | + $filtered = apply_filters('thinkrank_email_report_config', $report, $state); | |
| 102 | + $filtered = is_array($filtered) ? $filtered : $report; | |
| 124 | 103 | |
| 125 | - return $sanitized; | |
| 104 | + return array_merge( | |
| 105 | + $filtered, | |
| 106 | + [ | |
| 107 | + 'frequency_days' => max(1, (int) ($filtered['frequency_days'] ?? self::FREQUENCY_DAYS)), | |
| 108 | + 'recipients' => $this->normalize_recipients($filtered['recipients'] ?? []), | |
| 109 | + 'sections_enabled' => $this->normalize_section_keys($filtered['sections_enabled'] ?? []), | |
| 110 | + ], | |
| 111 | + $state | |
| 112 | + ); | |
| 126 | 113 | } |
| 127 | 114 | |
| 128 | 115 | /** |
| 129 | - * Pure sanitization — no DB writes. Useful for previews and tests. | |
| 116 | + * Save the on/off switch. Returns the resolved config after the write. | |
| 130 | 117 | * |
| 131 | - * Free vs. Pro behavior: Pro-only fields are accepted into the array | |
| 132 | - * even on free, but their values are coerced to defaults if the user | |
| 133 | - * isn't allowed to set them. Why keep them at all? So if the user | |
| 134 | - * upgrades, their previously-saved values aren't lost. | |
| 118 | + * The first enable seeds `next_scheduled_at` so the UI shows a real "Next | |
| 119 | + * report" date immediately. The scheduler still re-seeds on its first tick | |
| 120 | + * for any other path that flips enable on. | |
| 135 | 121 | */ |
| 136 | - public function sanitize(array $input): array { | |
| 137 | - $defaults = $this->defaults(); | |
| 138 | - $caps = Plan_Config::email_report(); | |
| 139 | - $limits = function_exists('thinkrank_get_email_report_field_limits') | |
| 140 | - ? thinkrank_get_email_report_field_limits() | |
| 141 | - : []; | |
| 122 | + public function save(array $input): array { | |
| 123 | + $this->load_defaults_file(); | |
| 142 | 124 | |
| 143 | - // Existing stored values are the baseline — partial updates (e.g. | |
| 144 | - // a toggle-only POST or a Pro field added later) merge over the | |
| 145 | - // saved config rather than reverting unsupplied keys to defaults. | |
| 146 | - $stored = get_option(self::OPTION_KEY, []); | |
| 147 | - if (!is_array($stored)) { | |
| 148 | - $stored = []; | |
| 125 | + $stored = $this->stored() + thinkrank_get_default_email_report_config(); | |
| 126 | + | |
| 127 | + if (array_key_exists('enabled', $input)) { | |
| 128 | + $stored['enabled'] = (bool) filter_var($input['enabled'], FILTER_VALIDATE_BOOLEAN); | |
| 149 | 129 | } |
| 150 | - $existing = array_merge($defaults, $stored); | |
| 151 | 130 | |
| 152 | - $clean = []; | |
| 131 | + if ($stored['enabled'] && empty($stored['next_scheduled_at'])) { | |
| 132 | + $next = strtotime('+' . $this->get()['frequency_days'] . ' days'); | |
| 153 | 133 | |
| 154 | - $clean['enabled'] = isset($input['enabled']) | |
| 155 | - ? !empty($input['enabled']) | |
| 156 | - : (bool) $existing['enabled']; | |
| 134 | + $stored['next_scheduled_at'] = wp_date('Y-m-d H:i:s', max($next ?: time(), time())); | |
| 135 | + } | |
| 157 | 136 | |
| 158 | - $clean['frequency_days'] = Plan_Config::clamp_email_report_frequency( | |
| 159 | - isset($input['frequency_days']) ? (int) $input['frequency_days'] : (int) $existing['frequency_days'] | |
| 160 | - ); | |
| 137 | + update_option(self::OPTION_KEY, $stored, false); | |
| 161 | 138 | |
| 162 | - $clean['recipients'] = Plan_Config::clamp_email_report_recipients( | |
| 163 | - $this->normalize_recipients($input['recipients'] ?? $existing['recipients']) | |
| 164 | - ); | |
| 139 | + $config = $this->get(); | |
| 165 | 140 | |
| 166 | - // Subject: free plan always uses the default. Pro: keep existing | |
| 167 | - // when input doesn't include the key, accept new when it does. | |
| 168 | - // | |
| 169 | - // The subject carries variable tags (%site_title%, %date%, %period%), | |
| 170 | - // so it is sanitized as a template: sanitize_text_field() reads %date% | |
| 171 | - // as percent-encoding and stores "te%" (#521). intro_text/footer_text | |
| 172 | - // take the same tags but go through wp_kses_post(), which leaves them | |
| 173 | - // alone, and header_background holds a colour rather than a template. | |
| 174 | - if (!empty($caps['custom_subject']) && array_key_exists('subject_template', $input)) { | |
| 175 | - $subject = Pattern_Resolver::sanitize_template((string) $input['subject_template']); | |
| 176 | - if ($subject === '') { | |
| 177 | - $subject = (string) $defaults['subject_template']; | |
| 178 | - } | |
| 179 | - } elseif (!empty($caps['custom_subject'])) { | |
| 180 | - $subject = (string) $existing['subject_template']; | |
| 181 | - } else { | |
| 182 | - $subject = (string) $defaults['subject_template']; | |
| 183 | - } | |
| 184 | - $clean['subject_template'] = $this->trim_to($subject, $limits['subject_template'] ?? 200); | |
| 141 | + /** | |
| 142 | + * Fires after Email Report config is saved. | |
| 143 | + * | |
| 144 | + * @since 1.9.0 | |
| 145 | + * | |
| 146 | + * @param array $config The resolved config. | |
| 147 | + */ | |
| 148 | + do_action('thinkrank_email_report_settings_saved', $config); | |
| 185 | 149 | |
| 186 | - // Logo URL: free plan stays null. Pro: only overwrite when the key | |
| 187 | - // is present in input (so partial updates don't blank the logo). | |
| 188 | - $clean['logo_url'] = $this->resolve_optional_url( | |
| 189 | - $caps, | |
| 190 | - 'custom_logo', | |
| 191 | - $input, | |
| 192 | - 'logo_url', | |
| 193 | - $existing['logo_url'] ?? null, | |
| 194 | - $limits['logo_url'] ?? 2048 | |
| 195 | - ); | |
| 150 | + return $config; | |
| 151 | + } | |
| 196 | 152 | |
| 197 | - $clean['logo_link'] = $this->resolve_optional_url( | |
| 198 | - $caps, | |
| 199 | - 'logo_link', | |
| 200 | - $input, | |
| 201 | - 'logo_link', | |
| 202 | - $existing['logo_link'] ?? null, | |
| 203 | - $limits['logo_link'] ?? 2048 | |
| 204 | - ); | |
| 153 | + /** | |
| 154 | + * Move the next send after the report's frequency changed. | |
| 155 | + * | |
| 156 | + * Called by whatever changed the frequency (ThinkRank Pro) with the value | |
| 157 | + * it had before. Carrying the old timestamp through meant switching 30 → 7 | |
| 158 | + * days still waited out the original 30-day window. The new date anchors | |
| 159 | + * off the last send when there is one, so shortening the cadence brings the | |
| 160 | + * next report forward instead of adding a full period on top of time | |
| 161 | + * already elapsed. | |
| 162 | + * | |
| 163 | + * @param int $previous_frequency_days Frequency before the change. | |
| 164 | + * @return array The resolved config. | |
| 165 | + */ | |
| 166 | + public function reschedule(int $previous_frequency_days): array { | |
| 167 | + $config = $this->get(); | |
| 205 | 168 | |
| 206 | - $clean['header_background'] = $this->resolve_optional_text( | |
| 207 | - $caps, | |
| 208 | - 'header_background', | |
| 209 | - $input, | |
| 210 | - 'header_background', | |
| 211 | - $existing['header_background'] ?? null, | |
| 212 | - $limits['header_background'] ?? 500 | |
| 213 | - ); | |
| 169 | + if (empty($config['enabled']) || $previous_frequency_days === (int) $config['frequency_days']) { | |
| 170 | + return $config; | |
| 171 | + } | |
| 214 | 172 | |
| 215 | - // Free is forced to the default toggle (true) so the dashboard CTA | |
| 216 | - // still appears. Pro: prefer input, fall back to existing, then default. | |
| 217 | - $clean['link_to_full_report'] = empty($caps['link_to_full_report']) | |
| 218 | - ? (bool) $defaults['link_to_full_report'] | |
| 219 | - : ( | |
| 220 | - array_key_exists('link_to_full_report', $input) | |
| 221 | - ? (bool) $input['link_to_full_report'] | |
| 222 | - : (bool) $existing['link_to_full_report'] | |
| 223 | - ); | |
| 173 | + // last_sent_at is a site-local wall clock (current_time('mysql')). | |
| 174 | + // strtotime() would read it as UTC and skew the whole cadence by the | |
| 175 | + // site's offset, so resolve it in the site timezone instead. | |
| 176 | + $anchor = !empty($config['last_sent_at']) | |
| 177 | + ? (int) get_gmt_from_date((string) $config['last_sent_at'], 'U') | |
| 178 | + : time(); | |
| 179 | + $anchor = $anchor ?: time(); | |
| 224 | 180 | |
| 225 | - $clean['intro_text'] = $this->resolve_optional_rich_text( | |
| 226 | - $caps, | |
| 227 | - 'intro_text', | |
| 228 | - $input, | |
| 229 | - 'intro_text', | |
| 230 | - $existing['intro_text'] ?? null, | |
| 231 | - $limits['intro_text'] ?? 5000 | |
| 232 | - ); | |
| 181 | + $next = strtotime('+' . (int) $config['frequency_days'] . ' days', $anchor); | |
| 233 | 182 | |
| 234 | - $clean['footer_text'] = $this->resolve_optional_rich_text( | |
| 235 | - $caps, | |
| 236 | - 'footer_text', | |
| 237 | - $input, | |
| 238 | - 'footer_text', | |
| 239 | - $existing['footer_text'] ?? null, | |
| 240 | - $limits['footer_text'] ?? 5000 | |
| 183 | + // Never schedule into the past — a big cadence cut on an old | |
| 184 | + // last_sent_at means "due now", which the next tick picks up. | |
| 185 | + return $this->update_schedule( | |
| 186 | + $config['last_sent_at'], | |
| 187 | + wp_date('Y-m-d H:i:s', max($next ?: time(), time())) | |
| 241 | 188 | ); |
| 189 | + } | |
| 242 | 190 | |
| 243 | - $clean['additional_css'] = empty($caps['additional_css']) | |
| 244 | - ? null | |
| 245 | - : ( | |
| 246 | - array_key_exists('additional_css', $input) | |
| 247 | - ? $this->sanitize_css($input['additional_css'], $limits['additional_css'] ?? 20000) | |
| 248 | - : ($existing['additional_css'] ?? null) | |
| 249 | - ); | |
| 191 | + /** | |
| 192 | + * Note why a scheduled run sent nothing (#742). | |
| 193 | + * | |
| 194 | + * `search_console_not_connected` leaves the schedule alone so the next | |
| 195 | + * hourly tick tries again; `no_data` is recorded by the generator after | |
| 196 | + * it has already pushed the schedule out a period. Either way the panel | |
| 197 | + * shows the reason and when it was last seen. | |
| 198 | + * | |
| 199 | + * @param string $reason Machine-readable reason. | |
| 200 | + */ | |
| 201 | + public function record_skip(string $reason): void { | |
| 202 | + $stored = $this->stored(); | |
| 203 | + $stored['last_skip'] = [ | |
| 204 | + 'reason' => sanitize_key($reason), | |
| 205 | + 'at' => current_time('mysql'), | |
| 206 | + ]; | |
| 207 | + update_option(self::OPTION_KEY, $stored, false); | |
| 208 | + } | |
| 250 | 209 | |
| 251 | - // Sections: Free is locked to all-on. Pro user submits the list, | |
| 252 | - // falling back to existing when the key is missing. | |
| 253 | - if (empty($caps['sections_configurable'])) { | |
| 254 | - $clean['sections_enabled'] = (array) $defaults['sections_enabled']; | |
| 255 | - } elseif (array_key_exists('sections_enabled', $input)) { | |
| 256 | - $clean['sections_enabled'] = $this->sanitize_section_keys($input['sections_enabled']); | |
| 257 | - } else { | |
| 258 | - $clean['sections_enabled'] = (array) $existing['sections_enabled']; | |
| 210 | + /** | |
| 211 | + * A report went out — whatever paused it earlier no longer applies. | |
| 212 | + */ | |
| 213 | + public function clear_skip(): void { | |
| 214 | + $stored = $this->stored(); | |
| 215 | + if (!array_key_exists('last_skip', $stored)) { | |
| 216 | + return; | |
| 259 | 217 | } |
| 260 | - | |
| 261 | - // Schedule timestamps are server-managed — never trust client input. | |
| 262 | - $clean['next_scheduled_at'] = $stored['next_scheduled_at'] ?? null; | |
| 263 | - $clean['last_sent_at'] = $stored['last_sent_at'] ?? null; | |
| 264 | - | |
| 265 | - /** | |
| 266 | - * Filter the sanitized config before persistence. | |
| 267 | - * | |
| 268 | - * Pro plugin uses this to sanitize fields it has added via | |
| 269 | - * `thinkrank_email_report_config_schema`. The filter receives the | |
| 270 | - * raw input alongside the sanitized output so consumers can read | |
| 271 | - * pro-only field intent without re-parsing the request. | |
| 272 | - * | |
| 273 | - * @since 1.9.0 | |
| 274 | - * | |
| 275 | - * @param array $clean Sanitized config so far. | |
| 276 | - * @param array $input Raw input as received. | |
| 277 | - */ | |
| 278 | - return apply_filters('thinkrank_email_report_config_sanitized', $clean, $input); | |
| 218 | + unset($stored['last_skip']); | |
| 219 | + update_option(self::OPTION_KEY, $stored, false); | |
| 279 | 220 | } |
| 280 | 221 | |
| 281 | 222 | /** |
| 282 | 223 | * Update only the schedule timestamps. Called from the scheduler after |
| 283 | - * a successful send so we don't round-trip the whole sanitize() flow | |
| 284 | - * (the rest of the config hasn't changed). | |
| 224 | + * a send. | |
| 285 | 225 | */ |
| 286 | 226 | public function update_schedule(?string $last_sent_at, ?string $next_scheduled_at): array { |
| 287 | - $current = $this->get(); | |
| 288 | - $current['last_sent_at'] = $last_sent_at; | |
| 289 | - $current['next_scheduled_at'] = $next_scheduled_at; | |
| 290 | - update_option(self::OPTION_KEY, $current, false); | |
| 291 | - return $current; | |
| 227 | + $stored = $this->stored(); | |
| 228 | + $stored['last_sent_at'] = $last_sent_at; | |
| 229 | + $stored['next_scheduled_at'] = $next_scheduled_at; | |
| 230 | + update_option(self::OPTION_KEY, $stored, false); | |
| 231 | + | |
| 232 | + return $this->get(); | |
| 292 | 233 | } |
| 293 | 234 | |
| 294 | 235 | /** |
| 295 | - * Normalize a recipient input that might arrive as a string | |
| 236 | + * Normalize a recipient list that might arrive as a string | |
| 296 | 237 | * ("a@x.com, b@x.com") or as an array. |
| 297 | 238 | * |
| 298 | 239 | * @param mixed $raw |
| 299 | 240 | * @return string[] |
| @@ -318,86 +259,19 @@ | ||
| 318 | 259 | return array_values(array_unique($emails)); |
| 319 | 260 | } |
| 320 | 261 | |
| 321 | 262 | /** |
| 322 | - * Resolve an optional URL field with partial-update semantics. | |
| 323 | - * Free plan: always null. Pro: prefer input, fall back to existing. | |
| 324 | - */ | |
| 325 | - private function resolve_optional_url(array $caps, string $cap_key, array $input, string $field, $existing, int $max_len): ?string { | |
| 326 | - if (empty($caps[$cap_key])) { | |
| 327 | - return null; | |
| 328 | - } | |
| 329 | - if (array_key_exists($field, $input)) { | |
| 330 | - return $this->sanitize_url($input[$field], $max_len); | |
| 331 | - } | |
| 332 | - return is_string($existing) && $existing !== '' ? $existing : null; | |
| 333 | - } | |
| 334 | - | |
| 335 | - private function resolve_optional_text(array $caps, string $cap_key, array $input, string $field, $existing, int $max_len): ?string { | |
| 336 | - if (empty($caps[$cap_key])) { | |
| 337 | - return null; | |
| 338 | - } | |
| 339 | - if (array_key_exists($field, $input)) { | |
| 340 | - $raw = $input[$field]; | |
| 341 | - return is_string($raw) | |
| 342 | - ? $this->trim_to(sanitize_text_field($raw), $max_len) | |
| 343 | - : null; | |
| 344 | - } | |
| 345 | - return is_string($existing) && $existing !== '' ? $existing : null; | |
| 346 | - } | |
| 347 | - | |
| 348 | - private function resolve_optional_rich_text(array $caps, string $cap_key, array $input, string $field, $existing, int $max_len): ?string { | |
| 349 | - if (empty($caps[$cap_key])) { | |
| 350 | - return null; | |
| 351 | - } | |
| 352 | - if (array_key_exists($field, $input)) { | |
| 353 | - return $this->sanitize_rich_text($input[$field], $max_len); | |
| 354 | - } | |
| 355 | - return is_string($existing) && $existing !== '' ? $existing : null; | |
| 356 | - } | |
| 357 | - | |
| 358 | - private function sanitize_url($raw, int $max_len): ?string { | |
| 359 | - if (!is_string($raw) || $raw === '') { | |
| 360 | - return null; | |
| 361 | - } | |
| 362 | - $url = esc_url_raw(trim($raw)); | |
| 363 | - if ($url === '') { | |
| 364 | - return null; | |
| 365 | - } | |
| 366 | - return $this->trim_to($url, $max_len); | |
| 367 | - } | |
| 368 | - | |
| 369 | - private function sanitize_rich_text($raw, int $max_len): ?string { | |
| 370 | - if (!is_string($raw) || $raw === '') { | |
| 371 | - return null; | |
| 372 | - } | |
| 373 | - $clean = wp_kses_post($raw); | |
| 374 | - return $this->trim_to($clean, $max_len); | |
| 375 | - } | |
| 376 | - | |
| 377 | - private function sanitize_css($raw, int $max_len): ?string { | |
| 378 | - if (!is_string($raw) || $raw === '') { | |
| 379 | - return null; | |
| 380 | - } | |
| 381 | - // wp_strip_all_tags + length cap is enough — actual CSS-in-email | |
| 382 | - // safety is an email-client problem we can't solve server-side. | |
| 383 | - $clean = wp_strip_all_tags($raw); | |
| 384 | - return $this->trim_to($clean, $max_len); | |
| 385 | - } | |
| 386 | - | |
| 387 | - /** | |
| 263 | + * Keep known section keys, in the order given. | |
| 264 | + * | |
| 388 | 265 | * @param mixed $raw |
| 389 | 266 | * @return string[] |
| 390 | 267 | */ |
| 391 | - private function sanitize_section_keys($raw): array { | |
| 268 | + private function normalize_section_keys($raw): array { | |
| 392 | 269 | if (!is_array($raw)) { |
| 393 | 270 | return []; |
| 394 | 271 | } |
| 395 | - $allowed = function_exists('thinkrank_get_email_report_default_sections') | |
| 396 | - ? array_keys(thinkrank_get_email_report_default_sections()) | |
| 397 | - : []; | |
| 398 | 272 | $allowed = array_unique(array_merge( |
| 399 | - $allowed, | |
| 273 | + array_keys(thinkrank_get_email_report_default_sections()), | |
| 400 | 274 | (array) apply_filters('thinkrank_email_report_section_keys', []) |
| 401 | 275 | )); |
| 402 | 276 | $clean = []; |
| 403 | 277 | foreach ($raw as $key) { |
| @@ -409,13 +283,6 @@ | ||
| 409 | 283 | $clean[] = $key; |
| 410 | 284 | } |
| 411 | 285 | } |
| 412 | 286 | return array_values(array_unique($clean)); |
| 413 | - } | |
| 414 | - | |
| 415 | - private function trim_to(string $value, int $max_len): string { | |
| 416 | - if (function_exists('mb_substr')) { | |
| 417 | - return mb_substr($value, 0, $max_len); | |
| 418 | - } | |
| 419 | - return substr($value, 0, $max_len); | |
| 420 | 287 | } |
| 421 | 288 | } |