[A-Za-z0-9]{32})/notes', [ [ 'callback' => [$this, 'list'], 'methods' => \WP_REST_Server::READABLE, 'permission_callback' => '__return_true', ], [ 'callback' => [$this, 'add'], 'methods' => \WP_REST_Server::CREATABLE, 'permission_callback' => '__return_true', ], ]); } private static function key(string $token): string { return 'sarcio_review_' . md5($token); } private static function byStageKey(string $stageToken): string { return 'sarcio_review_of_' . md5($stageToken); } /** @return array|null */ public static function get(string $token): ?array { if (!preg_match(self::TOKEN, $token)) { return null; } $stored = get_transient(self::key($token)); return is_array($stored) && is_array($stored['changes'] ?? null) ? $stored : null; } /** The active preview's review link, made on first ask and reused after. */ public static function create(): array { $stageToken = Stage::token(); if ($stageToken === null || Stage::changes() === []) { return ['error' => __('There is nothing in the preview to share.', 'sarcio'), 'ok' => false]; } $existing = get_transient(self::byStageKey($stageToken)); $review = is_string($existing) ? self::get($existing) : null; $token = is_string($existing) && $review !== null ? $existing : wp_generate_password(32, false); $review = [ 'changes' => Stage::changes(), 'createdAt' => $review['createdAt'] ?? time(), 'notes' => $review['notes'] ?? [], 'userId' => get_current_user_id(), ]; set_transient(self::key($token), $review, self::TTL_SEC); set_transient(self::byStageKey($stageToken), $token, self::TTL_SEC); return ['link' => self::link($token), 'notes' => $review['notes'], 'ok' => true, 'token' => $token]; } public static function link(string $token): string { return add_query_arg(self::PARAM, $token, home_url('/')); } /** The review record for a stage token, made on first ask: the owner's own notes live there too. */ public static function tokenForStage(?string $stageToken = null): ?string { $stageToken ??= Stage::token(); if ($stageToken === null) { return null; } $existing = get_transient(self::byStageKey($stageToken)); if (is_string($existing) && self::get($existing) !== null) { return $existing; } if (Stage::changes() === []) { return null; } $created = self::create(); return $created['ok'] ? (string) $created['token'] : null; } /** The owner's own preview: the same note bar, so they can pin what to change for the assistant. */ public static function hookOwnerBar(): void { $token = self::tokenForStage(); $review = $token !== null ? self::get($token) : null; if ($token === null || $review === null) { return; } self::enqueueBar($token, $review, true); } /** @param array $review */ private static function enqueueBar(string $token, array $review, bool $owner): void { add_action('wp_enqueue_scripts', static function () use ($token, $review, $owner): void { wp_register_script('sarcio-review', false, [], SARCIO_WP_VERSION, true); wp_enqueue_script('sarcio-review'); $path = dirname(__DIR__, 2) . '/assets/js/review.js'; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- the plugin's own bundled file. $script = is_readable($path) ? (string) file_get_contents($path) : ''; wp_add_inline_script('sarcio-review', 'window.sarcioReview = ' . InlineJson::encode([ 'brand' => Brand::name(), 'color' => Brand::color(), 'count' => count($review['changes']), 'owner' => $owner, 'restUrl' => esc_url_raw(rest_url(Routes::NAMESPACE . '/review/' . $token . '/notes')), 'strings' => [ 'bar' => $owner ? __('Your preview. Pin a note on anything you would change, then ask the assistant what the notes say.', 'sarcio') /* translators: %s: the brand name. */ : __('You are looking at a preview that %s shared with you. Nothing here is live yet.', 'sarcio'), 'leave' => __('Leave a note', 'sarcio'), 'leaving' => __('Click the spot you want to comment on', 'sarcio'), 'name' => __('Your name', 'sarcio'), 'note' => __('Your note', 'sarcio'), 'send' => __('Add note', 'sarcio'), 'cancel' => __('Cancel', 'sarcio'), 'thanks' => $owner ? __('Noted. Ask the assistant to read your notes.', 'sarcio') : __('Noted. The team will see it in Sarcio.', 'sarcio'), ], ]) . ';' . $script); }); } /** The notes left on the active preview's review link, for the assistant. */ public static function notesForStage(): array { $stageToken = Stage::token(); if ($stageToken === null) { return []; } $token = get_transient(self::byStageKey($stageToken)); $review = is_string($token) ? self::get($token) : null; return $review === null ? [] : array_values((array) $review['notes']); } /** * The front end, for a visitor holding the link: the `?sarcio_review=` * visit sets the cookie and lands on the clean URL; the cookie then * replays the snapshot on every page with the note bar on top. */ public static function hookFrontEnd(): bool { if (isset($_GET[self::PARAM])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a link token, checked against the stored review. $token = sanitize_text_field(wp_unslash((string) $_GET[self::PARAM])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if (self::get($token) !== null && !headers_sent()) { setcookie(self::COOKIE, $token, ['expires' => time() + self::TTL_SEC, 'httponly' => true, 'path' => defined('COOKIEPATH') && COOKIEPATH !== '' ? COOKIEPATH : '/', 'samesite' => 'Lax', 'secure' => is_ssl()]); wp_safe_redirect(remove_query_arg(self::PARAM)); exit; } } $token = isset($_COOKIE[self::COOKIE]) ? sanitize_text_field(wp_unslash((string) $_COOKIE[self::COOKIE])) : ''; $review = $token !== '' ? self::get($token) : null; if ($review === null) { return false; } $overlay = Stage::overlayFor($review['changes']); if (!$overlay->empty()) { Stage::hookOverlay($overlay); } self::enqueueBar($token, $review, false); return true; } public function list(WP_REST_Request $request): WP_REST_Response { $result = self::notes((string) $request['token'], sanitize_text_field((string) $request->get_param('page'))); return new WP_REST_Response($result, isset($result['error']) ? 404 : 200); } public function add(WP_REST_Request $request): WP_REST_Response { $body = $request->get_json_params(); $result = self::addNote((string) $request['token'], is_array($body) ? $body : []); return new WP_REST_Response($result, isset($result['error']) ? ($result['status'] ?? 400) : 200); } /** @return array{notes:array>}|array{error:string} */ public static function notes(string $token, string $page = ''): array { $review = self::get($token); if ($review === null) { return ['error' => __('That review link has expired.', 'sarcio')]; } return ['notes' => array_values(array_filter((array) $review['notes'], static fn (array $note): bool => $page === '' || $note['page'] === $page))]; } /** * @param array $body * @return array{ok:bool,note:array}|array{error:string,status:int} */ public static function addNote(string $token, array $body): array { $review = self::get($token); if ($review === null) { return ['error' => __('That review link has expired.', 'sarcio'), 'status' => 404]; } $text = sanitize_textarea_field((string) ($body['text'] ?? '')); if ($text === '') { return ['error' => __('Write the note first.', 'sarcio'), 'status' => 400]; } if (count((array) $review['notes']) >= self::MAX_NOTES) { return ['error' => __('This review holds as many notes as it can.', 'sarcio'), 'status' => 400]; } $note = [ 'at' => gmdate('c'), 'author' => mb_substr(sanitize_text_field((string) ($body['author'] ?? '')), 0, 80), 'id' => bin2hex(random_bytes(6)), 'page' => mb_substr(sanitize_text_field((string) ($body['page'] ?? '/')), 0, 300), 'selector' => mb_substr(sanitize_text_field((string) ($body['selector'] ?? '')), 0, 300), 'text' => mb_substr($text, 0, 1000), 'x' => max(0, min(100, (float) ($body['x'] ?? 0))), 'y' => max(0, min(100, (float) ($body['y'] ?? 0))), ]; $review['notes'][] = $note; set_transient(self::key($token), $review, self::TTL_SEC); return ['note' => $note, 'ok' => true]; } public static function abilities(): array { return [ new Ability( 'sarcio/review-notes', __('Look at the notes a reviewer left', 'sarcio'), Ability::READ, 'The notes a client or colleague pinned on the shared review link of the current preview: who, on which page, pointing at what, and what they wrote. Empty when no link was shared or nobody wrote yet.', ['properties' => [], 'required' => [], 'type' => 'object'], static fn (array $args): bool => current_user_can('edit_posts'), static fn (array $args): array => ['notes' => self::notesForStage()], ), ]; } }