> */ public static function all(): array { $stored = get_option(self::OPTION, []); return is_array($stored) ? array_filter($stored, 'is_array') : []; } /** @return array> pending first, then the most recent finished ones */ public static function list(): array { $all = array_values(self::all()); if (!current_user_can('manage_options')) { $all = array_values(array_filter($all, static fn (array $schedule): bool => (int) $schedule['userId'] === get_current_user_id())); } usort($all, static fn (array $a, array $b): int => [$a['status'] !== 'pending', $a['applyAt']] <=> [$b['status'] !== 'pending', $b['applyAt']]); return array_map([self::class, 'describe'], $all); } /** * Turns the active preview into a schedule and ends the preview. * * @param array $summaries what each staged change does, by id, as the chat shows it * @return array{ok:bool,error?:string,schedule?:array} */ public const REPEATS = ['none', 'daily', 'weekly', 'monthly']; public static function fromStage(string $applyAt, ?string $revertAt, array $summaries, string $repeat = 'none'): array { if (!in_array($repeat, self::REPEATS, true)) { return ['error' => __('That repeat is not one I know.', 'sarcio'), 'ok' => false]; } $changes = Stage::changes(); if (!Stage::active() || $changes === []) { return ['error' => __('There is nothing in the preview to schedule.', 'sarcio'), 'ok' => false]; } $applyTs = self::parse($applyAt); $revertTs = $revertAt !== null && $revertAt !== '' ? self::parse($revertAt) : null; $now = time(); if ($applyTs === null || $applyTs <= $now + 30) { return ['error' => __('Pick a time in the future for the change.', 'sarcio'), 'ok' => false]; } if ($applyTs > $now + self::MAX_AHEAD) { return ['error' => __('Changes can be scheduled up to a year ahead.', 'sarcio'), 'ok' => false]; } if ($revertAt !== null && $revertAt !== '' && ($revertTs === null || $revertTs <= $applyTs)) { return ['error' => __('The time to change it back must come after the change.', 'sarcio'), 'ok' => false]; } $items = []; foreach ($changes as $change) { $id = (string) $change['id']; $items[] = [ 'ability' => (string) $change['ability'], 'args' => $change['args'], 'id' => $id, 'label' => Catalog::label((string) $change['ability']), 'summary' => sanitize_text_field((string) ($summaries[$id] ?? '')), ]; } $schedule = [ 'applyAt' => $applyTs, 'changeIds' => [], 'changes' => $items, 'createdAt' => $now, 'id' => bin2hex(random_bytes(6)), 'repeat' => $repeat, 'results' => [], 'revertAt' => $revertTs, 'status' => 'pending', 'userId' => get_current_user_id(), ]; $all = self::all(); $all[$schedule['id']] = $schedule; self::store($all); wp_schedule_single_event($applyTs, self::APPLY_HOOK, [$schedule['id']]); Stage::clear((string) Stage::token()); return ['ok' => true, 'schedule' => self::describe($schedule)]; } /** Cancels a pending schedule, or the pending revert of an applied one. */ public static function cancel(string $id): array { $all = self::all(); $schedule = $all[$id] ?? null; if ($schedule === null) { return ['error' => __('That schedule is no longer there.', 'sarcio'), 'ok' => false]; } if ((int) $schedule['userId'] !== get_current_user_id() && !current_user_can('manage_options')) { return ['error' => __('Only the person who scheduled this, or an administrator, can cancel it.', 'sarcio'), 'ok' => false]; } if ($schedule['status'] === 'pending') { wp_unschedule_event((int) $schedule['applyAt'], self::APPLY_HOOK, [$id]); $schedule['status'] = 'cancelled'; } elseif ($schedule['status'] === 'applied' && $schedule['revertAt'] !== null) { wp_unschedule_event((int) $schedule['revertAt'], self::REVERT_HOOK, [$id]); $schedule['revertAt'] = null; } else { return ['error' => __('That schedule has already run.', 'sarcio'), 'ok' => false]; } $all[$id] = $schedule; self::store($all); return ['ok' => true, 'schedule' => self::describe($schedule)]; } /** WP-Cron: applies the schedule's changes as the member who made it. */ public static function runApply(string $id): void { $all = self::all(); $schedule = $all[$id] ?? null; if ($schedule === null || $schedule['status'] !== 'pending') { return; } $results = []; $changeIds = []; $failed = false; self::asUser((int) $schedule['userId'], static function () use ($schedule, &$results, &$changeIds, &$failed): void { foreach ($schedule['changes'] as $change) { $result = Catalog::run((string) $change['ability'], (array) $change['args']); $one = ['id' => $change['id'], 'ok' => (bool) $result['ok']]; if ($result['ok']) { $changeIds[] = (int) ($result['changeId'] ?? 0); } else { $one['error'] = (string) ($result['error'] ?? ''); $failed = true; } $results[] = $one; } }, static function (string $error) use (&$results, &$failed, $schedule): void { $failed = true; $results = array_map(static fn (array $change): array => ['error' => $error, 'id' => $change['id'], 'ok' => false], $schedule['changes']); }); $schedule['appliedAt'] = time(); $schedule['changeIds'] = array_values(array_filter($changeIds)); $schedule['results'] = $results; $schedule['status'] = $schedule['changeIds'] === [] ? 'failed' : ($failed ? 'partial' : 'applied'); if ($schedule['changeIds'] !== [] && $schedule['revertAt'] !== null) { wp_schedule_single_event((int) $schedule['revertAt'], self::REVERT_HOOK, [$id]); } $all[$id] = $schedule; if ($schedule['revertAt'] === null) { $all = self::repeatNext($all, $schedule); } self::store($all); } /** * A repeating schedule books its next occurrence once this one is done: * after the revert where there is one, else after the apply. The copy is * a fresh pending schedule, so each occurrence keeps its own results. * * @param array> $all * @param array $schedule * @return array> */ private static function repeatNext(array $all, array $schedule): array { $repeat = (string) ($schedule['repeat'] ?? 'none'); if ($repeat === 'none' || !empty($schedule['repeatStopped'])) { return $all; } $step = match ($repeat) { 'daily' => '+1 day', 'weekly' => '+1 week', default => '+1 month', }; $zone = wp_timezone(); $applyAt = (new \DateTimeImmutable('@' . (int) $schedule['applyAt']))->setTimezone($zone)->modify($step)->getTimestamp(); $revertAt = $schedule['revertAt'] !== null ? (new \DateTimeImmutable('@' . (int) $schedule['revertAt']))->setTimezone($zone)->modify($step)->getTimestamp() : null; while ($applyAt <= time()) { $applyAt = (new \DateTimeImmutable('@' . $applyAt))->setTimezone($zone)->modify($step)->getTimestamp(); $revertAt = $revertAt === null ? null : (new \DateTimeImmutable('@' . $revertAt))->setTimezone($zone)->modify($step)->getTimestamp(); } $next = [ 'applyAt' => $applyAt, 'changeIds' => [], 'changes' => $schedule['changes'], 'createdAt' => time(), 'id' => bin2hex(random_bytes(6)), 'repeat' => $repeat, 'repeatOf' => (string) $schedule['id'], 'results' => [], 'revertAt' => $revertAt, 'status' => 'pending', 'userId' => (int) $schedule['userId'], ]; $all[$next['id']] = $next; wp_schedule_single_event($applyAt, self::APPLY_HOOK, [$next['id']]); return $all; } /** WP-Cron: undoes what the schedule applied, newest change first. */ public static function runRevert(string $id): void { $all = self::all(); $schedule = $all[$id] ?? null; if ($schedule === null || !in_array($schedule['status'], ['applied', 'partial'], true) || $schedule['revertAt'] === null) { return; } $errors = []; self::asUser((int) $schedule['userId'], static function () use ($schedule, &$errors): void { foreach (array_reverse($schedule['changeIds']) as $changeId) { $undone = Catalog::undo((int) $changeId); if (!$undone['ok']) { $errors[] = (string) ($undone['error'] ?? ''); } } }, static function (string $error) use (&$errors): void { $errors[] = $error; }); $schedule['revertedAt'] = time(); $schedule['revertErrors'] = $errors; $schedule['status'] = $errors === [] ? 'reverted' : 'revert_failed'; $all[$id] = $schedule; $all = self::repeatNext($all, $schedule); self::store($all); } /** Puts back any cron event a deactivation dropped. */ public static function ensureScheduled(): void { foreach (self::all() as $id => $schedule) { if ($schedule['status'] === 'pending' && !wp_next_scheduled(self::APPLY_HOOK, [$id])) { wp_schedule_single_event(max(time() + 60, (int) $schedule['applyAt']), self::APPLY_HOOK, [$id]); } elseif (in_array($schedule['status'], ['applied', 'partial'], true) && $schedule['revertAt'] !== null && !isset($schedule['revertedAt']) && !wp_next_scheduled(self::REVERT_HOOK, [$id])) { wp_schedule_single_event(max(time() + 60, (int) $schedule['revertAt']), self::REVERT_HOOK, [$id]); } } } public static function unscheduleAll(): void { foreach (self::all() as $id => $schedule) { wp_unschedule_event((int) $schedule['applyAt'], self::APPLY_HOOK, [$id]); if ($schedule['revertAt'] !== null) { wp_unschedule_event((int) $schedule['revertAt'], self::REVERT_HOOK, [$id]); } } } /** A local wall-clock time ("2026-10-23T09:00") in the site's time zone, as a Unix timestamp. */ public static function parse(string $local): ?int { if (!preg_match('~^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$~', $local)) { return null; } try { $time = new \DateTimeImmutable($local, wp_timezone()); } catch (\Exception) { return null; } return $time->getTimestamp(); } /** @param array $schedule */ public static function describe(array $schedule): array { $format = static fn (?int $ts): ?string => $ts === null ? null : (string) wp_date('Y-m-d\TH:i', $ts, wp_timezone()); return [ 'applyAt' => $format((int) $schedule['applyAt']), 'appliedAt' => $format(isset($schedule['appliedAt']) ? (int) $schedule['appliedAt'] : null), 'changes' => array_map(static fn (array $change): array => ['id' => $change['id'], 'label' => $change['label'], 'summary' => $change['summary']], $schedule['changes']), 'id' => (string) $schedule['id'], 'repeat' => (string) ($schedule['repeat'] ?? 'none'), 'results' => $schedule['results'] ?? [], 'revertAt' => $format(isset($schedule['revertAt']) ? (int) $schedule['revertAt'] : null), 'revertErrors' => $schedule['revertErrors'] ?? [], 'revertedAt' => $format(isset($schedule['revertedAt']) ? (int) $schedule['revertedAt'] : null), 'status' => (string) $schedule['status'], 'userId' => (int) $schedule['userId'], ]; } /** @param array> $all */ private static function store(array $all): void { $finished = array_filter($all, static fn (array $schedule): bool => $schedule['status'] !== 'pending' && !($schedule['status'] === 'applied' && $schedule['revertAt'] !== null && !isset($schedule['revertedAt']))); if (count($finished) > self::KEEP_FINISHED) { uasort($finished, static fn (array $a, array $b): int => $a['applyAt'] <=> $b['applyAt']); foreach (array_slice(array_keys($finished), 0, count($finished) - self::KEEP_FINISHED) as $old) { unset($all[$old]); } } update_option(self::OPTION, $all, false); } /** Runs the callback as the member who scheduled, or tells the fallback why it cannot. */ private static function asUser(int $userId, callable $run, callable $fallback): void { $user = $userId > 0 ? get_userdata($userId) : false; if (!is_object($user)) { $fallback(__('The person who scheduled this no longer has an account here.', 'sarcio')); return; } $previous = get_current_user_id(); wp_set_current_user($userId); try { $run(); } finally { wp_set_current_user($previous); } } }