0, 'drafts' => [], 'conversations' => [], 'last' => null, 'lastPage' => 'home', 'history' => [], 'future' => [], 'remember' => false, ]; $data['scopes'] = ['page' => true, 'theme' => false]; $data['codeProposal'] = null; $data['historyArchive'] = []; $data['conversations'] = APEARI_Conversations::prune($data['conversations']); foreach (['history', 'future'] as $stack) { foreach ($data[$stack] as &$snapshot) { $snapshot['conversations'] = APEARI_Conversations::prune($snapshot['conversations'] ?? []); } unset($snapshot); } return $data; } public static function load(string $id) { $data = preg_match('/^[a-f0-9]{48}$/', $id) ? get_transient('apeari_session_' . $id) : false; if ( !is_array($data) || $data['user'] !== get_current_user_id() || $data['theme'] !== get_stylesheet() ) { return new WP_Error('apeari_session', __('Sesja wygasła. Otwórz edytor ponownie.', 'apeari'), [ 'status' => 410, ]); } return self::normalize($data); } public static function keep(string $id, array $data): void { $data = self::normalize($data); set_transient('apeari_session_' . $id, $data, 2 * HOUR_IN_SECONDS); APEARI_Privacy::track('apeari_session_' . $id); APEARI_Conversations::remember($id, $data); } public static function mutate(string $id, callable $callback) { $lock = 'apeari_session_lock_' . md5($id); if ((int) get_option($lock) < time() - 120) { delete_option($lock); } if (!add_option($lock, time(), '', false)) { return new WP_Error('apeari_busy', __('Poprzedni zapis jeszcze trwa.', 'apeari'), [ 'status' => 409, ]); } try { $data = self::load($id); return is_wp_error($data) ? $data : $callback($data); } finally { delete_option($lock); } } public static function page(array $data, string $ref) { return $ref === $data['lastPage'] && preg_match('/^page:[1-9][0-9]*$/', $ref) ? APEARI_Documents::get($ref) : new WP_Error('apeari_scope', __('Edycja dotyczy wyłącznie wybranej strony.', 'apeari'), [ 'status' => 403, ]); } public static function public_state(array $data): array { $drafts = array_values($data['drafts']); foreach ($drafts as &$draft) { $draft['version'] = hash('sha256', $draft['content']); } return [ 'revision' => $data['revision'], 'drafts' => $drafts, 'scopes' => $data['scopes'], 'lastPage' => $data['lastPage'], 'conversations' => $data['conversations'], 'history' => ['undo' => count($data['history']), 'redo' => count($data['future'])], 'canRevert' => !empty($data['last']), ]; } private static function snapshot(array &$data): void { $data['history'][] = ['drafts' => $data['drafts'], 'conversations' => $data['conversations']]; $data['history'] = array_slice($data['history'], -12); $data['future'] = []; } public static function workspace(array $input) { $id = (string) ($input['sessionId'] ?? ''); return self::mutate($id, static function ($data) use ($id, $input) { if (!empty($input['scopes']['theme'])) { return new WP_Error('apeari_scope', __('Ta edycja nie udostępnia motywu.', 'apeari'), [ 'status' => 403, ]); } $ref = (string) ($input['pageRef'] ?? $data['lastPage']); if ($ref !== $data['lastPage'] && $data['drafts']) { return new WP_Error( 'apeari_dirty', __('Opublikuj albo odrzuć szkic przed zmianą strony.', 'apeari'), [ 'status' => 409, ], ); } if ($ref !== $data['lastPage']) { $data['history'] = []; $data['future'] = []; $data['last'] = null; } $data['lastPage'] = $ref; $documents = []; if ($ref !== 'home') { $doc = self::page($data, $ref); if (is_wp_error($doc)) { return $doc; } $doc['content'] = $data['drafts'][$ref]['content'] ?? $doc['content']; $doc['version'] = hash('sha256', $doc['content']); $documents[] = APEARI_Documents::public_data($doc); } self::keep($id, $data); return self::public_state($data) + ['documents' => $documents]; }); } public static function preview(array $input) { $id = (string) ($input['sessionId'] ?? ''); return self::mutate($id, static function ($data) use ($id, $input) { if (($input['revision'] ?? -1) !== $data['revision']) { return APEARI_Context::stale(); } if (!empty($input['clear'])) { self::snapshot($data); $data['drafts'] = []; ++$data['revision']; self::keep($id, $data); return self::public_state($data); } $items = $input['items'] ?? [$input]; $thread = (string) ($input['threadKey'] ?? 'page:' . $data['lastPage']); if ( !preg_match( '/^(?:page:page:[1-9][0-9]*|element:page:[1-9][0-9]*:[0-9]+(?:\.[0-9]+)*)$/', $thread, ) || !str_starts_with( $thread, str_starts_with($thread, 'element:') ? 'element:' . $data['lastPage'] . ':' : 'page:' . $data['lastPage'], ) ) { return new WP_Error('apeari_thread', __('Nieprawidłowa rozmowa.', 'apeari'), [ 'status' => 422, ]); } if ($items === [] && isset($input['conversation'])) { APEARI_Conversations::store($data, $thread, $input['conversation']); ++$data['revision']; self::keep($id, $data); return self::public_state($data); } if (!is_array($items) || count($items) !== 1) { return new WP_Error('apeari_scope', __('Zapisz jeden dokument wybranej strony.', 'apeari'), [ 'status' => 403, ]); } $item = $items[0]; $doc = self::page($data, (string) ($item['ref'] ?? '')); if (is_wp_error($doc)) { return $doc; } $current = $data['drafts'][$doc['ref']]['content'] ?? $doc['content']; if ( ($item['baseHash'] ?? '') !== $doc['hash'] || ($item['baseVersion'] ?? '') !== hash('sha256', $current) ) { return APEARI_Context::stale(); } if (!is_string($item['content'] ?? null)) { return new WP_Error('apeari_content', __('Brak treści.', 'apeari'), ['status' => 422]); } $safe = APEARI_Block_Policy::validate($doc['content'], $item['content']); if (is_wp_error($safe)) { return $safe; } $threads = self::remap_threads($data['conversations'], $input, $doc['ref']); if (is_wp_error($threads)) { return $threads; } self::snapshot($data); $data['conversations'] = $threads; if ($item['content'] === $doc['content']) { unset($data['drafts'][$doc['ref']]); } else { $data['drafts'][$doc['ref']] = [ 'ref' => $doc['ref'], 'baseHash' => $doc['hash'], 'content' => $item['content'], ]; } if (isset($input['conversation'])) { APEARI_Conversations::store($data, $thread, $input['conversation']); } ++$data['revision']; self::keep($id, $data); return self::public_state($data); }); } public static function history(array $input) { $id = (string) ($input['sessionId'] ?? ''); return self::mutate($id, static function ($data) use ($id, $input) { if (($input['revision'] ?? -1) !== $data['revision']) { return APEARI_Context::stale(); } $from = ($input['action'] ?? '') === 'redo' ? 'future' : 'history'; $to = $from === 'history' ? 'future' : 'history'; if (!$data[$from]) { return new WP_Error('apeari_history', __('Brak zmiany do cofnięcia.', 'apeari'), [ 'status' => 409, ]); } $next = array_pop($data[$from]); $data[$to][] = ['drafts' => $data['drafts'], 'conversations' => $data['conversations']]; $data['drafts'] = $next['drafts']; $data['conversations'] = $next['conversations']; ++$data['revision']; self::keep($id, $data); return self::public_state($data); }); } private static function remap_threads(array $original, array $input, string $ref) { $moves = $input['threadMoves'] ?? []; $deletes = $input['threadDeletes'] ?? []; $error = static fn() => new WP_Error( 'apeari_thread', __('Nieprawidłowe przypisanie rozmów do bloków.', 'apeari'), ['status' => 422], ); if (!is_array($moves) || !is_array($deletes) || count($moves) > 30 || count($deletes) > 30) { return $error(); } $pattern = '/^element:' . preg_quote($ref, '/') . ':[0-9]+(?:\.[0-9]+)*$/'; $destinations = []; foreach ($deletes as $key) { if (!is_string($key) || !preg_match($pattern, $key)) { return $error(); } } foreach ($moves as $from => $to) { if ( !is_string($from) || !is_string($to) || strlen($to) > 240 || !isset($original[$from]) || !preg_match($pattern, $from) || !preg_match($pattern, $to) || isset($destinations[$to]) ) { return $error(); } if ( isset($original[$to]) && !array_key_exists($to, $moves) && !in_array($to, $deletes, true) && $from !== $to ) { return $error(); } $destinations[$to] = true; } $result = $original; foreach (array_merge(array_keys($moves), $deletes) as $key) { unset($result[$key]); } foreach ($moves as $from => $to) { $result[$to] = $original[$from]; } return $result; } public static function write(array $input, bool $revert = false) { $id = (string) ($input['sessionId'] ?? ''); return self::mutate($id, static function ($data) use ($id, $input, $revert) { if (($input['revision'] ?? -1) !== $data['revision']) { return APEARI_Context::stale(); } $change = $revert ? $data['last'] : $data['drafts'][$data['lastPage']] ?? null; if (!$change || ($revert && $data['drafts'])) { return new WP_Error('apeari_empty', __('Brak zmiany do zapisania.', 'apeari'), [ 'status' => 409, ]); } $doc = self::page($data, $change['ref']); if (is_wp_error($doc)) { return $doc; } if ($doc['hash'] !== ($revert ? $change['afterHash'] : $change['baseHash'])) { return APEARI_Context::stale(); } require_once ABSPATH . 'wp-admin/includes/post.php'; if (wp_check_post_lock((int) substr($doc['ref'], 5))) { return new WP_Error( 'apeari_locked', __('Strona jest edytowana przez inną osobę.', 'apeari'), [ 'status' => 409, ], ); } $content = $revert ? $change['before'] : $change['content']; $safe = APEARI_Block_Policy::validate($doc['content'], $content); if (is_wp_error($safe)) { return $safe; } $saved = APEARI_Documents::save($doc, $content); if (is_wp_error($saved)) { return $saved; } clean_post_cache($saved); $next = APEARI_Documents::get($doc['ref']); if ( is_wp_error($next) || str_replace("\r\n", "\n", $next['content']) !== str_replace("\r\n", "\n", APEARI_Documents::editable_content($content)) ) { APEARI_Documents::save($doc, $doc['originalContent']); return new WP_Error( 'apeari_write', __('WordPress nie zachował treści. Przywrócono poprzednią wersję.', 'apeari'), ['status' => 500], ); } $data['last'] = $revert ? null : ['ref' => $doc['ref'], 'before' => $doc['originalContent'], 'afterHash' => $next['hash']]; $data['drafts'] = []; $data['history'] = []; $data['future'] = []; ++$data['revision']; self::keep($id, $data); return self::public_state($data); }); } }