class-ajax-functions.php
4 days ago
class-define-constant.php
4 days ago
class-functions.php
4 days ago
do-it.php
4 days ago
inject-script.php
4 days ago
do-it.php
2154 lines
| 1 | <?php |
| 2 | |
| 3 | if ( ! defined('ABSPATH') ) exit; |
| 4 | |
| 5 | /** |
| 6 | * Whether Do It is enabled for this site. Gates every Do It surface: the inline |
| 7 | * config, the block identity markers, and the REST routes. |
| 8 | */ |
| 9 | function atarim_doit_enabled() { |
| 10 | return ! empty( get_option('avc_enable_doit', false) ); |
| 11 | } |
| 12 | |
| 13 | /** |
| 14 | * Whether this request carries a valid Atarim server-to-server token. Lets the |
| 15 | * backend drive Do It with no WordPress session, the same auth the MCP endpoint |
| 16 | * and the WP Activity Log routes use. |
| 17 | */ |
| 18 | function atarim_doit_token_request() { |
| 19 | if ( ! class_exists('AVCF_MCP_Auth') ) return false; |
| 20 | $auth = new AVCF_MCP_Auth(); |
| 21 | return (bool) $auth->avcf_mcp_validate_request(); |
| 22 | } |
| 23 | |
| 24 | /** |
| 25 | * Whether the current user may edit the given post through the cookie-authenticated |
| 26 | * Do It routes. Only the write credentials are gated on this; targeting data is |
| 27 | * public, because a task may be left by any visitor. |
| 28 | */ |
| 29 | function atarim_doit_user_can_edit($post_id) { |
| 30 | if ( ! is_user_logged_in() ) return false; |
| 31 | $post_id = (int) $post_id; |
| 32 | return $post_id ? current_user_can('edit_post', $post_id) : current_user_can('edit_posts'); |
| 33 | } |
| 34 | |
| 35 | /** |
| 36 | * Whether it is worth emitting Do It's targeting data on this request. |
| 37 | * |
| 38 | * The inline config and the block-identity attributes are read by exactly one |
| 39 | * consumer: the Atarim collaboration script. When that script is not being emitted |
| 40 | * — most anonymous traffic on a site whose collaboration is restricted — the |
| 41 | * attributes are never read, and injecting them costs a WP_HTML_Tag_Processor pass |
| 42 | * over every rendered block for nothing. Measured at +43% block-render time on a |
| 43 | * 4,200-block page, so it is worth asking before doing the work. |
| 44 | * |
| 45 | * Falls back to "yes" if the injector is unavailable, so a load-order change |
| 46 | * degrades to the previous behaviour rather than silently disabling Do It. |
| 47 | */ |
| 48 | function atarim_doit_markers_wanted() { |
| 49 | if ( ! atarim_doit_enabled() ) return false; |
| 50 | |
| 51 | if ( ! function_exists('atarim_collab_script_will_load') ) return true; |
| 52 | |
| 53 | return atarim_collab_script_will_load(); |
| 54 | } |
| 55 | |
| 56 | add_action('wp_enqueue_scripts', function () { |
| 57 | |
| 58 | if ( ! is_singular() ) return; |
| 59 | if ( ! atarim_doit_markers_wanted() ) return; |
| 60 | |
| 61 | $post_id = get_queried_object_id(); |
| 62 | if ( ! $post_id ) return; |
| 63 | |
| 64 | $page_builder = atarim_detect_page_builder($post_id); |
| 65 | $wrapper_hint = atarim_detect_wrapper_selector_by_theme(); // '' if unknown |
| 66 | |
| 67 | $handle = 'atarim-do-it'; |
| 68 | wp_register_script($handle, '', [], '0.3.1', false); |
| 69 | wp_enqueue_script($handle); |
| 70 | |
| 71 | // Targeting data is public. Anyone may leave a task, and the task has to |
| 72 | // record what it points at; postId is not a secret, WordPress already exposes |
| 73 | // it as the postid-N body class and the ?p=N shortlink. |
| 74 | $atarim_inline_data = [ |
| 75 | 'postId' => (int) $post_id, |
| 76 | 'pageBuilder' => $page_builder, |
| 77 | 'wrapperHint' => $wrapper_hint, |
| 78 | ]; |
| 79 | |
| 80 | // Write credentials stay gated on the edit capability. |
| 81 | if ( atarim_doit_user_can_edit($post_id) ) { |
| 82 | $atarim_inline_data['apiGet'] = esc_url_raw(rest_url('atarim/v1/content/get')); |
| 83 | $atarim_inline_data['apiSave'] = esc_url_raw(rest_url('atarim/v1/content/save')); |
| 84 | $atarim_inline_data['apiMediaImport'] = esc_url_raw(rest_url('atarim/v1/media/import')); |
| 85 | $atarim_inline_data['nonce'] = wp_create_nonce('wp_rest'); |
| 86 | } |
| 87 | |
| 88 | $atarim_inline_data = apply_filters('atarim_inline_data', $atarim_inline_data, $post_id); |
| 89 | wp_localize_script($handle, 'ATARIM_INLINE', $atarim_inline_data); |
| 90 | |
| 91 | /* This is how to use filter |
| 92 | add_filter('atarim_inline_data', function ($data, $post_id) { |
| 93 | if (empty($data['wrapperHint'])) { |
| 94 | $data['wrapperHint'] = '.site-main .entry-content'; |
| 95 | } |
| 96 | return $data; |
| 97 | }, 10, 2);*/ |
| 98 | }); |
| 99 | |
| 100 | /* =========================== |
| 101 | * Block identity injection |
| 102 | * Marks every rendered block with data-atarim-block-id (the durable |
| 103 | * metadata.avcBlockId), plus data-atarim-block-name / -anchor-index as a |
| 104 | * fallback for blocks that have not been stamped yet. |
| 105 | * |
| 106 | * Public on purpose: a task may be left by any visitor, and the task has to |
| 107 | * record which block it points at. Only the write credentials are capability |
| 108 | * gated (see the enqueue above). |
| 109 | * =========================== */ |
| 110 | add_action('template_redirect', function () { |
| 111 | |
| 112 | if ( ! is_singular() ) return; |
| 113 | if ( ! atarim_doit_markers_wanted() ) return; |
| 114 | |
| 115 | $post_id = get_queried_object_id(); |
| 116 | if ( ! $post_id ) return; |
| 117 | |
| 118 | if ( atarim_detect_page_builder($post_id) !== 'block' ) return; |
| 119 | |
| 120 | atarim_ensure_post_stamped($post_id); |
| 121 | |
| 122 | $GLOBALS['atarim_block_counters'] = []; |
| 123 | $GLOBALS['atarim_post_block_index'] = atarim_build_post_block_index($post_id); |
| 124 | |
| 125 | add_filter('render_block', 'atarim_inject_block_identity', 10, 2); |
| 126 | }); |
| 127 | |
| 128 | /** |
| 129 | * Pre-order index of every block in the queried post's content, keyed by avcBlockId. |
| 130 | * |
| 131 | * Two divergences made the render-time counter unusable as a target, and this map |
| 132 | * closes both: |
| 133 | * |
| 134 | * - Scope. The counter increments for every block on the PAGE, so on a block theme |
| 135 | * the header and footer template parts consume indices that |
| 136 | * atarim_collect_matching_blocks — which walks post_content alone — never sees. |
| 137 | * A block theme's post-content group came out as index 4 in the DOM and 0 in |
| 138 | * storage, so Do It could not address it at all. |
| 139 | * - Order. render_block fires inner-to-outer, so nested blocks were counted in |
| 140 | * completion order while post_content is walked pre-order. |
| 141 | * |
| 142 | * Building the map from post_content means the emitted index is the one the lookup |
| 143 | * side actually uses, and blocks outside post_content are simply absent — which is |
| 144 | * correct, because they are not editable through the content routes. |
| 145 | * |
| 146 | * @return array<string, array{index:int, path:string, name:string}> |
| 147 | */ |
| 148 | function atarim_build_post_block_index($post_id) { |
| 149 | |
| 150 | if ( ! class_exists('AVCF_Gutenberg_Helpers') ) return []; |
| 151 | |
| 152 | $post = get_post($post_id); |
| 153 | if ( ! $post ) return []; |
| 154 | |
| 155 | // Cached against the content hash: this is a full parse_blocks walk on every |
| 156 | // front-end render, and it measured 14ms on a 4,200-block page. The key changes |
| 157 | // whenever the content does, so the cache cannot go stale. |
| 158 | $hash = md5($post->post_content); |
| 159 | $cache_key = 'avc_block_index_' . $post_id; |
| 160 | $cached = get_transient($cache_key); |
| 161 | |
| 162 | if (is_array($cached) && ($cached['hash'] ?? null) === $hash) { |
| 163 | return $cached['map']; |
| 164 | } |
| 165 | |
| 166 | $map = []; |
| 167 | $counters = []; |
| 168 | atarim_walk_post_blocks(parse_blocks($post->post_content), $map, $counters); |
| 169 | |
| 170 | set_transient($cache_key, ['hash' => $hash, 'map' => $map], DAY_IN_SECONDS); |
| 171 | |
| 172 | return $map; |
| 173 | } |
| 174 | |
| 175 | /** |
| 176 | * @param array<string, array{index:int, path:string, name:string}> $map |
| 177 | * @param array<string, int> $counters |
| 178 | */ |
| 179 | function atarim_walk_post_blocks(array $blocks, array &$map, array &$counters, string $parent_path = ''): void { |
| 180 | |
| 181 | foreach ($blocks as $i => $block) { |
| 182 | if (!is_array($block) || empty($block['blockName'])) continue; |
| 183 | |
| 184 | $name = (string) $block['blockName']; |
| 185 | $path = $parent_path === '' ? (string) $i : $parent_path . '.' . $i; |
| 186 | |
| 187 | if (!isset($counters[$name])) { $counters[$name] = 0; } |
| 188 | $index = $counters[$name]++; |
| 189 | |
| 190 | $id = AVCF_Gutenberg_Helpers::extract_id($block['attrs'] ?? []); |
| 191 | if ($id !== '') { |
| 192 | $map[$id] = ['index' => $index, 'path' => $path, 'name' => $name]; |
| 193 | } |
| 194 | |
| 195 | if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) { |
| 196 | atarim_walk_post_blocks($block['innerBlocks'], $map, $counters, $path); |
| 197 | } |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | /** |
| 202 | * Give every block in the post a durable metadata.avcBlockId, once. |
| 203 | * |
| 204 | * anchor_index cannot be the primary target: it counts render order across the |
| 205 | * WHOLE page, so on a block theme the header and footer template parts consume |
| 206 | * indices that atarim_collect_matching_blocks (which walks post_content alone) |
| 207 | * never sees, and render_block fires inner-to-outer so nested blocks are counted |
| 208 | * in completion order rather than document order. A stored id sidesteps both, and |
| 209 | * survives the block being moved, reordered, or edited in Gutenberg. |
| 210 | * |
| 211 | * Costs one write per post, ever. The stamp hash is compared first so the common |
| 212 | * path is a single meta read with no block parsing. |
| 213 | */ |
| 214 | function atarim_ensure_post_stamped($post_id) { |
| 215 | |
| 216 | // The helpers only load when the Abilities API + MCP Adapter are present |
| 217 | // (see avcf-cluster-loader.php). Without them there is no id to stamp and |
| 218 | // targeting falls back to anchor_index. |
| 219 | if ( ! class_exists('AVCF_Gutenberg_Helpers') ) return; |
| 220 | |
| 221 | $post = get_post($post_id); |
| 222 | if ( ! $post || ! AVCF_Gutenberg_Helpers::is_block_based($post->post_content) ) return; |
| 223 | |
| 224 | if ( get_post_meta($post_id, '_avc_stamp_hash', true) === md5($post->post_content) ) return; |
| 225 | |
| 226 | // A concurrent render must not stamp the same post twice: the loser would |
| 227 | // reassign fresh ids and orphan any task captured against the winner's. |
| 228 | $lock = 'avc_stamp_lock_' . $post_id; |
| 229 | if ( get_transient($lock) ) return; |
| 230 | set_transient($lock, 1, 30); |
| 231 | |
| 232 | $blocks = AVCF_Gutenberg_Helpers::parse_raw($post->post_content); |
| 233 | $blocks = atarim_dedupe_block_ids(AVCF_Gutenberg_Helpers::raw_stamp_ids($blocks)); |
| 234 | $stamped = AVCF_Gutenberg_Helpers::serialize_raw($blocks); |
| 235 | |
| 236 | if ($stamped !== $post->post_content) { |
| 237 | // Slash-safe: post_content round-trips through wp_unslash on save. |
| 238 | wp_update_post(['ID' => $post_id, 'post_content' => wp_slash($stamped)]); |
| 239 | clean_post_cache($post_id); |
| 240 | atarim_refresh_queried_post_content($post_id, $stamped); |
| 241 | } |
| 242 | |
| 243 | update_post_meta($post_id, '_avc_stamp_hash', md5($stamped)); |
| 244 | delete_transient($lock); |
| 245 | } |
| 246 | |
| 247 | /** |
| 248 | * Give a fresh id to any block repeating an id already seen in this post. |
| 249 | * |
| 250 | * Duplicating a block in Gutenberg copies its attributes verbatim, avcBlockId |
| 251 | * included, and raw_stamp_ids only fills blocks that have NO id — so a duplicate |
| 252 | * keeps the original's. Lookups return the first match, meaning two tasks pinned |
| 253 | * to the two copies would both edit the first one. |
| 254 | * |
| 255 | * The first occurrence in document order keeps the id, so a task captured before |
| 256 | * the duplication still resolves to the block it was pinned to. |
| 257 | * |
| 258 | * @param array<int, mixed> $blocks |
| 259 | * @return array<int, mixed> |
| 260 | */ |
| 261 | function atarim_dedupe_block_ids(array $blocks): array { |
| 262 | $seen = []; |
| 263 | |
| 264 | return atarim_dedupe_block_ids_walk($blocks, $seen); |
| 265 | } |
| 266 | |
| 267 | /** |
| 268 | * @param array<int, mixed> $blocks |
| 269 | * @param array<string, bool> $seen |
| 270 | * @return array<int, mixed> |
| 271 | */ |
| 272 | function atarim_dedupe_block_ids_walk(array $blocks, array &$seen): array { |
| 273 | foreach ($blocks as $i => $block) { |
| 274 | if (!is_array($block) || empty($block['blockName'])) continue; |
| 275 | |
| 276 | $attrs = isset($block['attrs']) && is_array($block['attrs']) ? $block['attrs'] : []; |
| 277 | $id = AVCF_Gutenberg_Helpers::extract_id($attrs); |
| 278 | |
| 279 | if ($id !== '') { |
| 280 | if (isset($seen[$id])) { |
| 281 | $blocks[$i]['attrs'] = AVCF_Gutenberg_Helpers::set_id($attrs, wp_generate_uuid4()); |
| 282 | } else { |
| 283 | $seen[$id] = true; |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) { |
| 288 | $blocks[$i]['innerBlocks'] = atarim_dedupe_block_ids_walk($block['innerBlocks'], $seen); |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | return $blocks; |
| 293 | } |
| 294 | |
| 295 | /** |
| 296 | * Push freshly stamped content into the already-built main query. |
| 297 | * |
| 298 | * The main query runs before template_redirect, so without this the request that |
| 299 | * triggers the first stamp renders the pre-stamp content and emits no block ids — |
| 300 | * and a task created on that pageview would fall back to anchor_index, the very |
| 301 | * mode the ids exist to replace. |
| 302 | */ |
| 303 | function atarim_refresh_queried_post_content($post_id, $content) { |
| 304 | |
| 305 | global $wp_query; |
| 306 | |
| 307 | if (isset($GLOBALS['post']) && (int) $GLOBALS['post']->ID === (int) $post_id) { |
| 308 | $GLOBALS['post']->post_content = $content; |
| 309 | } |
| 310 | |
| 311 | if ($wp_query instanceof WP_Query) { |
| 312 | if ($wp_query->post && (int) $wp_query->post->ID === (int) $post_id) { |
| 313 | $wp_query->post->post_content = $content; |
| 314 | } |
| 315 | foreach ($wp_query->posts as $queried) { |
| 316 | if (is_object($queried) && (int) $queried->ID === (int) $post_id) { |
| 317 | $queried->post_content = $content; |
| 318 | } |
| 319 | } |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | function atarim_inject_block_identity(string $block_content, array $block): string { |
| 324 | |
| 325 | if (empty($block['blockName'])) return $block_content; |
| 326 | if (trim($block_content) === '') return $block_content; |
| 327 | |
| 328 | $block_name = $block['blockName']; |
| 329 | $block_id = class_exists('AVCF_Gutenberg_Helpers') |
| 330 | ? AVCF_Gutenberg_Helpers::extract_id($block['attrs'] ?? []) |
| 331 | : ''; |
| 332 | |
| 333 | $post_index = $GLOBALS['atarim_post_block_index'] ?? []; |
| 334 | |
| 335 | // A block carrying an id that the post-content map knows: emit the index the |
| 336 | // lookup side computes, and nothing else needs to be guessed. |
| 337 | if ($block_id !== '' && isset($post_index[$block_id])) { |
| 338 | $anchor_index = $post_index[$block_id]['index']; |
| 339 | } elseif ($post_index !== []) { |
| 340 | // The map is populated but this block is not in it, so it belongs to a |
| 341 | // template part, pattern or query loop rather than the post's own content. |
| 342 | // Those cannot be edited through the content routes, so marking them would |
| 343 | // only invite a target that can never resolve. |
| 344 | return $block_content; |
| 345 | } else { |
| 346 | // No map (helpers unavailable, so nothing is stamped): fall back to the |
| 347 | // render-order counter. Wrong on block themes, but it is all there is. |
| 348 | if (!isset($GLOBALS['atarim_block_counters'][$block_name])) { |
| 349 | $GLOBALS['atarim_block_counters'][$block_name] = 0; |
| 350 | } |
| 351 | $anchor_index = $GLOBALS['atarim_block_counters'][$block_name]; |
| 352 | $GLOBALS['atarim_block_counters'][$block_name]++; |
| 353 | } |
| 354 | |
| 355 | if (class_exists('WP_HTML_Tag_Processor')) { |
| 356 | $tags = new WP_HTML_Tag_Processor($block_content); |
| 357 | if ($tags->next_tag()) { |
| 358 | $tags->set_attribute('data-atarim-block-name', $block_name); |
| 359 | $tags->set_attribute('data-atarim-anchor-index', (string) $anchor_index); |
| 360 | if ($block_id !== '') { |
| 361 | $tags->set_attribute('data-atarim-block-id', $block_id); |
| 362 | } |
| 363 | return $tags->get_updated_html(); |
| 364 | } |
| 365 | return $block_content; |
| 366 | } |
| 367 | |
| 368 | // Fallback for older WP versions |
| 369 | $attrs = sprintf( |
| 370 | ' data-atarim-block-name="%s" data-atarim-anchor-index="%d"', |
| 371 | esc_attr($block_name), |
| 372 | $anchor_index |
| 373 | ); |
| 374 | if ($block_id !== '') { |
| 375 | $attrs .= sprintf(' data-atarim-block-id="%s"', esc_attr($block_id)); |
| 376 | } |
| 377 | |
| 378 | return preg_replace( |
| 379 | '/^(\s*<[a-zA-Z][a-zA-Z0-9]*)\b/', |
| 380 | '$1' . $attrs, |
| 381 | $block_content, |
| 382 | 1 |
| 383 | ); |
| 384 | } |
| 385 | |
| 386 | /* =========================== |
| 387 | * Post-title identity marker (for editors only) |
| 388 | * Adds a bare data-atarim-post-title attribute to the element that renders the |
| 389 | * dynamic post title — the core/post-title block (Gutenberg) and the |
| 390 | * theme-post-title widget (Elementor) — so the frontend can recognise the |
| 391 | * title with certainty and route edits to the post_title save path. No value is |
| 392 | * needed: the frontend already has the post ID via ATARIM_INLINE.postId |
| 393 | * (get_queried_object_id()). |
| 394 | * =========================== */ |
| 395 | add_action('template_redirect', function () { |
| 396 | |
| 397 | if ( ! is_singular() ) return; |
| 398 | if ( ! atarim_doit_markers_wanted() ) return; |
| 399 | if ( ! get_queried_object_id() ) return; |
| 400 | |
| 401 | // Gutenberg: core/post-title block. |
| 402 | add_filter('render_block_core/post-title', 'atarim_mark_post_title_block', 10, 3); |
| 403 | |
| 404 | // Elementor: theme-post-title widget. |
| 405 | add_filter('elementor/widget/render_content', 'atarim_mark_post_title_elementor', 10, 2); |
| 406 | }); |
| 407 | |
| 408 | /** |
| 409 | * Add a bare data-atarim-post-title attribute to the root tag of the rendered |
| 410 | * core/post-title block. Skips title blocks that render a DIFFERENT post inside |
| 411 | * a query loop (only the queried post's own title is marked). |
| 412 | */ |
| 413 | function atarim_mark_post_title_block($block_content, $block = [], $instance = null) { |
| 414 | if (trim((string) $block_content) === '') return $block_content; |
| 415 | |
| 416 | if ($instance instanceof WP_Block && isset($instance->context['postId'])) { |
| 417 | if ((int) $instance->context['postId'] !== (int) get_queried_object_id()) { |
| 418 | return $block_content; |
| 419 | } |
| 420 | } |
| 421 | |
| 422 | return atarim_add_post_title_attr($block_content); |
| 423 | } |
| 424 | |
| 425 | /** |
| 426 | * Add a bare data-atarim-post-title attribute to the title tag inside Elementor's |
| 427 | * theme-post-title widget. Skips loop items that render a different post. |
| 428 | */ |
| 429 | function atarim_mark_post_title_elementor($content, $widget) { |
| 430 | if (! is_object($widget) || ! method_exists($widget, 'get_name')) return $content; |
| 431 | if ($widget->get_name() !== 'theme-post-title') return $content; |
| 432 | if (trim((string) $content) === '') return $content; |
| 433 | |
| 434 | // In an Elementor loop the global post is swapped per item; only mark the |
| 435 | // queried post's own title. |
| 436 | $current = get_the_ID(); |
| 437 | if ($current && (int) $current !== (int) get_queried_object_id()) { |
| 438 | return $content; |
| 439 | } |
| 440 | |
| 441 | return atarim_add_post_title_attr($content); |
| 442 | } |
| 443 | |
| 444 | /** |
| 445 | * Set a bare data-atarim-post-title attribute on the first tag of $html. |
| 446 | */ |
| 447 | function atarim_add_post_title_attr(string $html): string { |
| 448 | if (class_exists('WP_HTML_Tag_Processor')) { |
| 449 | $tags = new WP_HTML_Tag_Processor($html); |
| 450 | if ($tags->next_tag()) { |
| 451 | $tags->set_attribute('data-atarim-post-title', true); // boolean true => bare attribute |
| 452 | return $tags->get_updated_html(); |
| 453 | } |
| 454 | return $html; |
| 455 | } |
| 456 | |
| 457 | // Fallback for older WP: inject a bare attribute into the first tag. |
| 458 | return preg_replace('/^(\s*<[a-zA-Z][a-zA-Z0-9]*)\b/', '$1 data-atarim-post-title', $html, 1); |
| 459 | } |
| 460 | |
| 461 | add_action('rest_api_init', function () { |
| 462 | |
| 463 | $permission_callback = function( WP_REST_Request $request ) { |
| 464 | if ( ! atarim_doit_enabled() ) { |
| 465 | return new WP_Error( |
| 466 | 'avc_doit_disabled', |
| 467 | __( 'Do It via Atarim AI is disabled for this site. Enable it from the Atarim plugin settings to allow execution.', 'atarim-visual-collaboration' ), |
| 468 | [ 'status' => 403 ] |
| 469 | ); |
| 470 | } |
| 471 | |
| 472 | // Server-to-server: Atarim's backend runs Do It without a WordPress |
| 473 | // session, authenticating with the shared secret from site connection. |
| 474 | if ( atarim_doit_token_request() ) return true; |
| 475 | |
| 476 | $post_id = absint($request->get_param('postId')); |
| 477 | if ($post_id) return current_user_can('edit_post', $post_id); |
| 478 | return current_user_can('edit_posts'); |
| 479 | }; |
| 480 | |
| 481 | register_rest_route('atarim/v1', '/content/get', [ |
| 482 | 'methods' => 'POST', |
| 483 | 'callback' => 'atarim_inline_get_handler', |
| 484 | 'permission_callback' => $permission_callback, |
| 485 | ]); |
| 486 | |
| 487 | register_rest_route('atarim/v1', '/content/save', [ |
| 488 | 'methods' => 'POST', |
| 489 | 'callback' => 'atarim_inline_save_handler', |
| 490 | 'permission_callback' => $permission_callback, |
| 491 | ]); |
| 492 | |
| 493 | // Media import: push external file URLs (e.g. task/comment attachments) into |
| 494 | // the media library, unattached. Own permission — needs upload_files, not the |
| 495 | // post-scoped edit_post the content routes use. |
| 496 | $media_permission_callback = function ( WP_REST_Request $request ) { |
| 497 | if ( ! atarim_doit_enabled() ) { |
| 498 | return new WP_Error( |
| 499 | 'avc_doit_disabled', |
| 500 | __( 'Do It via Atarim AI is disabled for this site. Enable it from the Atarim plugin settings to allow execution.', 'atarim-visual-collaboration' ), |
| 501 | [ 'status' => 403 ] |
| 502 | ); |
| 503 | } |
| 504 | if ( atarim_doit_token_request() ) return true; |
| 505 | return current_user_can( 'upload_files' ); |
| 506 | }; |
| 507 | |
| 508 | register_rest_route('atarim/v1', '/media/import', [ |
| 509 | 'methods' => 'POST', |
| 510 | 'callback' => 'atarim_inline_media_import_handler', |
| 511 | 'permission_callback' => $media_permission_callback, |
| 512 | ]); |
| 513 | |
| 514 | // Undo: restore the revision a Do It save created. Revision-based rather than |
| 515 | // replaying the stored original, so it is exact and cannot resurrect content |
| 516 | // that changed for unrelated reasons after the Do It landed. |
| 517 | register_rest_route('atarim/v1', '/doit/undo', [ |
| 518 | 'methods' => 'POST', |
| 519 | 'callback' => 'atarim_doit_undo_handler', |
| 520 | 'permission_callback' => $permission_callback, |
| 521 | ]); |
| 522 | |
| 523 | // --------------------------------------------------------------------- |
| 524 | // Core connection probe — NOT a DoIt route. Public and intentionally |
| 525 | // ungated: the Atarim app calls it cross-origin and unauthenticated, |
| 526 | // before any connection/token exists, to decide "Connect" vs "Install". |
| 527 | // It deliberately does NOT use $permission_callback (the avc_enable_doit |
| 528 | // gate) above. Lives here only because this file already registers the |
| 529 | // atarim/v1 namespace; move to its own home if more core routes appear. |
| 530 | // --------------------------------------------------------------------- |
| 531 | register_rest_route('atarim/v1', '/status', [ |
| 532 | 'methods' => 'GET', |
| 533 | 'permission_callback' => '__return_true', |
| 534 | 'callback' => function () { |
| 535 | $connected = get_option('avc_collab_active', 'no') === 'yes'; |
| 536 | return [ |
| 537 | 'installed' => true, |
| 538 | 'connected' => $connected, |
| 539 | 'version' => defined('AVCF_VERSION') ? AVCF_VERSION : null, |
| 540 | 'settings_url' => admin_url('options-general.php?page=atarim-visual-collaboration'), |
| 541 | 'site_url' => site_url(), |
| 542 | ]; |
| 543 | }, |
| 544 | ]); |
| 545 | }); |
| 546 | |
| 547 | /* =========================== |
| 548 | * Builder + wrapper detection |
| 549 | * =========================== */ |
| 550 | |
| 551 | /** |
| 552 | * Which builder owns this post's content. |
| 553 | * |
| 554 | * Order matters. Every meta-stored builder is checked before the block and classic |
| 555 | * fallbacks, because those builders often leave rendered HTML or shortcodes behind |
| 556 | * in post_content — and the classic branch would then happily rewrite a copy that |
| 557 | * the builder regenerates and overwrites, losing the edit silently. |
| 558 | */ |
| 559 | function atarim_detect_page_builder(int $post_id): string { |
| 560 | |
| 561 | foreach (atarim_doit_builders() as $slug => $builder) { |
| 562 | if (($builder['detect'])($post_id)) return $slug; |
| 563 | } |
| 564 | |
| 565 | $post = get_post($post_id); |
| 566 | if ($post && isset($post->post_content) && has_blocks($post->post_content)) return 'block'; |
| 567 | |
| 568 | if ($post && trim(wp_strip_all_tags($post->post_content)) !== '' && preg_match('/<[a-z][a-z0-9]*\b[^>]*>/i', $post->post_content)) { |
| 569 | return 'classic'; |
| 570 | } |
| 571 | |
| 572 | return ''; |
| 573 | } |
| 574 | |
| 575 | /** |
| 576 | * Meta-stored builders Do It can address, in detection order. |
| 577 | * |
| 578 | * Each entry declares where its content lives and how to find one element inside |
| 579 | * it. `node` is whatever the builder puts in the rendered DOM to identify an |
| 580 | * element — Beaver Builder's data-node, Elementor's data-id — so the frontend can |
| 581 | * capture it and the backend can address it later with no browser. |
| 582 | * |
| 583 | * `durable` records whether that identifier survives the element being moved or |
| 584 | * reordered. Where it is false, a Do It captured earlier can drift onto the wrong |
| 585 | * element, exactly as Gutenberg's anchor_index did before block ids. |
| 586 | * |
| 587 | * @return array<string, array{meta_key:string, dom_attr:string, durable:bool, detect:callable}> |
| 588 | */ |
| 589 | function atarim_doit_builders(): array { |
| 590 | return [ |
| 591 | 'elementor' => [ |
| 592 | 'meta_key' => '_elementor_data', |
| 593 | 'dom_attr' => 'data-id', |
| 594 | 'durable' => true, |
| 595 | 'detect' => function ($post_id) { |
| 596 | $d = get_post_meta($post_id, '_elementor_data', true); |
| 597 | return (is_string($d) && trim($d) !== '') || (is_array($d) && !empty($d)); |
| 598 | }, |
| 599 | ], |
| 600 | 'beaver' => [ |
| 601 | 'meta_key' => '_fl_builder_data', |
| 602 | 'dom_attr' => 'data-node', |
| 603 | 'durable' => true, |
| 604 | 'detect' => function ($post_id) { |
| 605 | if (empty(get_post_meta($post_id, '_fl_builder_enabled', true))) return false; |
| 606 | $d = get_post_meta($post_id, '_fl_builder_data', true); |
| 607 | return is_array($d) && !empty($d); |
| 608 | }, |
| 609 | ], |
| 610 | 'siteorigin' => [ |
| 611 | 'meta_key' => 'panels_data', |
| 612 | 'dom_attr' => 'id', |
| 613 | 'durable' => false, |
| 614 | 'detect' => function ($post_id) { |
| 615 | $d = get_post_meta($post_id, 'panels_data', true); |
| 616 | return is_array($d) && !empty($d['widgets']); |
| 617 | }, |
| 618 | ], |
| 619 | |
| 620 | // Detected in order to REFUSE. Both compile their editor state down into |
| 621 | // post_content, so that copy is derived, not source: an edit written there |
| 622 | // survives until the next time someone opens the builder and saves, at |
| 623 | // which point it is regenerated and the change vanishes with no error. |
| 624 | // Without these entries both fall through to the classic branch — which |
| 625 | // would happily edit the derived copy — so detecting them is what prevents |
| 626 | // the silent loss. |
| 627 | 'vc' => [ |
| 628 | 'meta_key' => 'vcv-pageContent', |
| 629 | 'dom_attr' => 'data-vcv-element', |
| 630 | 'durable' => true, |
| 631 | 'derived' => true, |
| 632 | 'detect' => function ($post_id) { |
| 633 | return get_post_meta($post_id, 'vcv-pageContent', true) !== ''; |
| 634 | }, |
| 635 | ], |
| 636 | 'brizy' => [ |
| 637 | 'meta_key' => 'brizy', |
| 638 | 'dom_attr' => 'data-uid', |
| 639 | 'durable' => true, |
| 640 | 'derived' => true, |
| 641 | 'detect' => function ($post_id) { |
| 642 | return get_post_meta($post_id, 'brizy-post-hash', true) !== '' |
| 643 | || get_post_meta($post_id, 'brizy', true) !== ''; |
| 644 | }, |
| 645 | ], |
| 646 | ]; |
| 647 | } |
| 648 | |
| 649 | /** |
| 650 | * Builders whose post_content is a compiled artifact rather than the source. |
| 651 | * Editing it appears to work and is then discarded on the builder's next save, so |
| 652 | * Do It refuses rather than writing. |
| 653 | */ |
| 654 | function atarim_doit_builder_is_derived(string $page_builder): bool { |
| 655 | $builders = atarim_doit_builders(); |
| 656 | |
| 657 | return ! empty($builders[$page_builder]['derived']); |
| 658 | } |
| 659 | |
| 660 | function atarim_detect_wrapper_selector_by_theme(): string { |
| 661 | |
| 662 | $theme = wp_get_theme(); |
| 663 | $template = strtolower((string) $theme->get_template()); |
| 664 | $stylesheet = strtolower((string) $theme->get_stylesheet()); |
| 665 | $slug = $template ?: $stylesheet; |
| 666 | |
| 667 | $map = [ |
| 668 | 'hello-elementor' => '.page-content', |
| 669 | 'oceanwp' => '.entry.clr', |
| 670 | // Expand later... |
| 671 | ]; |
| 672 | |
| 673 | return $map[$slug] ?? ''; |
| 674 | } |
| 675 | |
| 676 | |
| 677 | /** |
| 678 | * Save one element of a meta-stored builder (Beaver Builder, SiteOrigin). |
| 679 | * |
| 680 | * Shares the Elementor contract: `content` is the replacement node as JSON, the |
| 681 | * node id rides in `widgetId`, and the pre-write meta is snapshotted so undo has |
| 682 | * something to restore — these builders create no WordPress revision. |
| 683 | */ |
| 684 | function atarim_doit_save_meta_builder(WP_REST_Request $request, int $post_id, string $page_builder) { |
| 685 | |
| 686 | $builders = atarim_doit_builders(); |
| 687 | $meta_key = $builders[$page_builder]['meta_key'] ?? ''; |
| 688 | $node_id = sanitize_text_field((string) $request->get_param('widgetId')); |
| 689 | $content = $request->get_param('content'); |
| 690 | |
| 691 | if ($node_id === '') { |
| 692 | return new WP_REST_Response(['status'=>false,'message'=>'Missing widgetId.'], 400); |
| 693 | } |
| 694 | if (!is_array($content) && !is_object($content)) { |
| 695 | return new WP_REST_Response(['status'=>false,'message'=>'content must be a JSON object for this builder.'], 400); |
| 696 | } |
| 697 | |
| 698 | $before = get_post_meta($post_id, $meta_key, true); |
| 699 | $undo_token = atarim_doit_store_undo_snapshot($post_id, $page_builder, $before, $meta_key); |
| 700 | |
| 701 | $result = $page_builder === 'beaver' |
| 702 | ? atarim_beaver_replace_node($post_id, $node_id, $content) |
| 703 | : atarim_siteorigin_replace_widget($post_id, $node_id, (array) $content); |
| 704 | |
| 705 | if (!$result['ok']) { |
| 706 | return new WP_REST_Response(['status'=>false,'message'=>$result['message']], 404); |
| 707 | } |
| 708 | |
| 709 | atarim_doit_after_content_save($post_id, $page_builder); |
| 710 | |
| 711 | $stored = get_post_meta($post_id, $meta_key, true); |
| 712 | $receipt = atarim_inline_save_receipt( |
| 713 | wp_json_encode($stored), |
| 714 | wp_json_encode($stored), |
| 715 | wp_json_encode($before), |
| 716 | null |
| 717 | ); |
| 718 | $receipt['revisionId'] = $undo_token; |
| 719 | $receipt['undoKind'] = 'snapshot'; |
| 720 | |
| 721 | return new WP_REST_Response(array_merge( |
| 722 | ['status'=>true, 'target'=>$page_builder, 'widgetId'=>$node_id], |
| 723 | $receipt |
| 724 | ), 200); |
| 725 | } |
| 726 | |
| 727 | /* =========================== |
| 728 | * Beaver Builder |
| 729 | * =========================== */ |
| 730 | |
| 731 | /** |
| 732 | * Beaver Builder keys _fl_builder_data by node id and renders that same id as |
| 733 | * data-node, so an element captured from the DOM is addressable forever — no |
| 734 | * stamping needed, and immune to the reordering that breaks positional schemes. |
| 735 | * |
| 736 | * @return object|null |
| 737 | */ |
| 738 | function atarim_beaver_find_node(int $post_id, string $node_id) { |
| 739 | $data = get_post_meta($post_id, '_fl_builder_data', true); |
| 740 | if (!is_array($data) || !isset($data[$node_id])) return null; |
| 741 | return $data[$node_id]; |
| 742 | } |
| 743 | |
| 744 | /** |
| 745 | * @return array{ok:bool, message:string} |
| 746 | */ |
| 747 | function atarim_beaver_replace_node(int $post_id, string $node_id, $node): array { |
| 748 | $data = get_post_meta($post_id, '_fl_builder_data', true); |
| 749 | if (!is_array($data) || !isset($data[$node_id])) { |
| 750 | return ['ok' => false, 'message' => 'Node not found; nothing saved.']; |
| 751 | } |
| 752 | |
| 753 | $existing = $data[$node_id]; |
| 754 | |
| 755 | // Beaver Builder reads node properties as objects all the way down |
| 756 | // ($node->settings->size), and a JSON body decodes to nested arrays. A |
| 757 | // shallow (object) cast leaves settings as an array, which BB then cannot |
| 758 | // read — so round-trip through JSON to convert every level. |
| 759 | $incoming = json_decode(wp_json_encode($node)); |
| 760 | |
| 761 | if (!is_object($incoming) || (string) ($incoming->node ?? '') !== $node_id) { |
| 762 | return ['ok' => false, 'message' => 'content.node must match the requested node id.']; |
| 763 | } |
| 764 | |
| 765 | // Type and parentage describe where the node sits in the tree; letting a |
| 766 | // content edit change them would silently restructure the layout. |
| 767 | $incoming->type = $existing->type ?? ($incoming->type ?? ''); |
| 768 | $incoming->parent = $existing->parent ?? ($incoming->parent ?? null); |
| 769 | $incoming->position = $existing->position ?? ($incoming->position ?? 0); |
| 770 | |
| 771 | $data[$node_id] = $incoming; |
| 772 | update_post_meta($post_id, '_fl_builder_data', $data); |
| 773 | |
| 774 | return ['ok' => true, 'message' => 'Saved.']; |
| 775 | } |
| 776 | |
| 777 | /* =========================== |
| 778 | * SiteOrigin Page Builder |
| 779 | * =========================== */ |
| 780 | |
| 781 | /** |
| 782 | * SiteOrigin has no per-widget identity: panels_data['widgets'] is a flat list and |
| 783 | * the rendered id is panel-{post}-{grid}-{cell}-{i}, derived from position. The |
| 784 | * only addressing available is that position, so a Do It captured before a |
| 785 | * reorder can land on the wrong widget — the same failure block ids fixed for |
| 786 | * Gutenberg. Callers get `durable => false` from atarim_doit_builders() and should |
| 787 | * re-read before writing. |
| 788 | * |
| 789 | * @return array{index:int, widget:array}|null |
| 790 | */ |
| 791 | function atarim_siteorigin_find_widget(int $post_id, string $node_id): ?array { |
| 792 | $data = get_post_meta($post_id, 'panels_data', true); |
| 793 | if (!is_array($data) || empty($data['widgets'])) return null; |
| 794 | |
| 795 | // Accept both the raw list index and the rendered DOM id. |
| 796 | if (preg_match('/^(?:panel-)?(?:\d+-)?(\d+)-(\d+)-(\d+)$/', $node_id, $m)) { |
| 797 | [$grid, $cell, $within] = [(int) $m[1], (int) $m[2], (int) $m[3]]; |
| 798 | foreach ($data['widgets'] as $i => $widget) { |
| 799 | $info = $widget['panels_info'] ?? $widget['info'] ?? []; |
| 800 | if ((int) ($info['grid'] ?? -1) === $grid |
| 801 | && (int) ($info['cell'] ?? -1) === $cell |
| 802 | && (int) ($info['id'] ?? -1) === $within) { |
| 803 | return ['index' => $i, 'widget' => $widget]; |
| 804 | } |
| 805 | } |
| 806 | return null; |
| 807 | } |
| 808 | |
| 809 | $index = (int) $node_id; |
| 810 | if (!isset($data['widgets'][$index])) return null; |
| 811 | |
| 812 | return ['index' => $index, 'widget' => $data['widgets'][$index]]; |
| 813 | } |
| 814 | |
| 815 | /** |
| 816 | * @return array{ok:bool, message:string} |
| 817 | */ |
| 818 | function atarim_siteorigin_replace_widget(int $post_id, string $node_id, $widget): array { |
| 819 | $data = get_post_meta($post_id, 'panels_data', true); |
| 820 | $found = atarim_siteorigin_find_widget($post_id, $node_id); |
| 821 | |
| 822 | if ($found === null || !is_array($data)) { |
| 823 | return ['ok' => false, 'message' => 'Widget not found; nothing saved.']; |
| 824 | } |
| 825 | if (!is_array($widget)) { |
| 826 | return ['ok' => false, 'message' => 'SiteOrigin content must be a JSON object.']; |
| 827 | } |
| 828 | |
| 829 | // panels_info carries the grid/cell placement; preserve it so an edit cannot |
| 830 | // move the widget into a different cell. |
| 831 | $widget['panels_info'] = $found['widget']['panels_info'] ?? ($widget['panels_info'] ?? []); |
| 832 | |
| 833 | $data['widgets'][$found['index']] = $widget; |
| 834 | update_post_meta($post_id, 'panels_data', $data); |
| 835 | |
| 836 | return ['ok' => true, 'message' => 'Saved.']; |
| 837 | } |
| 838 | |
| 839 | /* =========================== |
| 840 | * REST: UNDO |
| 841 | * =========================== */ |
| 842 | |
| 843 | /** |
| 844 | * Restore the revision a Do It save created, reverting the post to its state |
| 845 | * immediately before that write. |
| 846 | */ |
| 847 | function atarim_doit_undo_handler(WP_REST_Request $request) { |
| 848 | |
| 849 | $post_id = absint($request->get_param('postId')); |
| 850 | $revision_id = absint($request->get_param('revisionId')); |
| 851 | |
| 852 | if (!$post_id || !$revision_id) { |
| 853 | return new WP_REST_Response(['status'=>false,'message'=>'Missing postId or revisionId.'], 400); |
| 854 | } |
| 855 | |
| 856 | // Meta-stored builders (Elementor and friends) have no WordPress revision, so |
| 857 | // their undo restores the snapshot taken before the write instead. |
| 858 | $snapshot = atarim_doit_restore_undo_snapshot($post_id, $revision_id); |
| 859 | if ($snapshot['restored']) { |
| 860 | return new WP_REST_Response([ |
| 861 | 'status' => true, |
| 862 | 'target' => 'undo', |
| 863 | 'undoKind' => 'snapshot', |
| 864 | 'postId' => $post_id, |
| 865 | 'revisionId' => $revision_id, |
| 866 | 'changed' => true, |
| 867 | ], 200); |
| 868 | } |
| 869 | |
| 870 | $revision = wp_get_post_revision($revision_id); |
| 871 | if (!$revision) { |
| 872 | return new WP_REST_Response(['status'=>false,'message'=>'Revision not found.'], 404); |
| 873 | } |
| 874 | if ((int) $revision->post_parent !== $post_id) { |
| 875 | return new WP_REST_Response(['status'=>false,'message'=>'Revision does not belong to this post.'], 400); |
| 876 | } |
| 877 | |
| 878 | $before = (string) get_post_field('post_content', $post_id); |
| 879 | |
| 880 | $restored = wp_restore_post_revision($revision_id); |
| 881 | if ($restored === null || is_wp_error($restored)) { |
| 882 | return new WP_REST_Response([ |
| 883 | 'status'=>false, |
| 884 | 'message'=>'Failed to restore revision' . (is_wp_error($restored) ? ': ' . $restored->get_error_message() : '.'), |
| 885 | ], 500); |
| 886 | } |
| 887 | |
| 888 | clean_post_cache($post_id); |
| 889 | |
| 890 | $stored = (string) get_post_field('post_content', $post_id); |
| 891 | |
| 892 | return new WP_REST_Response([ |
| 893 | 'status' => true, |
| 894 | 'target' => 'undo', |
| 895 | 'postId' => $post_id, |
| 896 | 'revisionId' => $revision_id, |
| 897 | 'changed' => sha1($before) !== sha1($stored), |
| 898 | 'storedSha1' => sha1($stored), |
| 899 | ], 200); |
| 900 | } |
| 901 | |
| 902 | /* =========================== |
| 903 | * REST: GET |
| 904 | * =========================== */ |
| 905 | |
| 906 | function atarim_inline_get_handler(WP_REST_Request $request) { |
| 907 | |
| 908 | $post_id = absint($request->get_param('postId')); |
| 909 | $page_builder = sanitize_text_field((string) $request->get_param('pageBuilder')); |
| 910 | |
| 911 | if (!$post_id) { |
| 912 | return new WP_REST_Response(['status' => false, 'message' => 'Missing postId.'], 400); |
| 913 | } |
| 914 | |
| 915 | // Post-title target: independent of the page builder (the title is the |
| 916 | // queried post's post_title, wherever/however the theme renders it). |
| 917 | if ($request->get_param('target') === 'post_title') { |
| 918 | $post = get_post($post_id); |
| 919 | if (!$post) return new WP_REST_Response(['status'=>false,'message'=>'Post not found.'], 404); |
| 920 | |
| 921 | return new WP_REST_Response([ |
| 922 | 'status' => true, |
| 923 | 'target' => 'post_title', |
| 924 | 'postId' => $post_id, |
| 925 | 'title' => $post->post_title, |
| 926 | 'slug' => $post->post_name, |
| 927 | ], 200); |
| 928 | } |
| 929 | |
| 930 | if (atarim_doit_builder_is_derived($page_builder)) { |
| 931 | return new WP_REST_Response([ |
| 932 | 'status' => false, |
| 933 | 'message' => sprintf( |
| 934 | 'This page uses %s, which regenerates the page from its own stored copy. An edit made here would be discarded the next time the page is saved in the builder, so Do It will not write to it.', |
| 935 | $page_builder === 'vc' ? 'Visual Composer' : 'Brizy' |
| 936 | ), |
| 937 | ], 400); |
| 938 | } |
| 939 | |
| 940 | if (!in_array($page_builder, ['elementor', 'beaver', 'siteorigin', 'block', 'classic'], true)) { |
| 941 | return new WP_REST_Response([ |
| 942 | 'status' => false, |
| 943 | 'message' => 'This page builder is not supported yet.', |
| 944 | ], 400); |
| 945 | } |
| 946 | |
| 947 | $post = get_post($post_id); |
| 948 | if (!$post) return new WP_REST_Response(['status'=>false,'message'=>'Post not found.'], 404); |
| 949 | |
| 950 | if ($page_builder === 'beaver') { |
| 951 | $node_id = sanitize_text_field((string) $request->get_param('widgetId')); |
| 952 | if (!$node_id) return new WP_REST_Response(['status'=>false,'message'=>'Missing widgetId (Beaver Builder data-node).'], 400); |
| 953 | |
| 954 | $node = atarim_beaver_find_node($post_id, $node_id); |
| 955 | if ($node === null) return new WP_REST_Response(['status'=>false,'message'=>'Node not found.'], 404); |
| 956 | |
| 957 | return new WP_REST_Response([ |
| 958 | 'status' => true, |
| 959 | 'pageBuilder' => 'beaver', |
| 960 | 'postId' => $post_id, |
| 961 | 'widgetId' => $node_id, |
| 962 | 'durable' => true, |
| 963 | 'content' => $node, |
| 964 | ], 200); |
| 965 | } |
| 966 | |
| 967 | if ($page_builder === 'siteorigin') { |
| 968 | $node_id = sanitize_text_field((string) $request->get_param('widgetId')); |
| 969 | if ($node_id === '') return new WP_REST_Response(['status'=>false,'message'=>'Missing widgetId (SiteOrigin panel id or index).'], 400); |
| 970 | |
| 971 | $found = atarim_siteorigin_find_widget($post_id, $node_id); |
| 972 | if ($found === null) return new WP_REST_Response(['status'=>false,'message'=>'Widget not found.'], 404); |
| 973 | |
| 974 | return new WP_REST_Response([ |
| 975 | 'status' => true, |
| 976 | 'pageBuilder' => 'siteorigin', |
| 977 | 'postId' => $post_id, |
| 978 | 'widgetId' => $node_id, |
| 979 | 'durable' => false, |
| 980 | 'content' => $found['widget'], |
| 981 | ], 200); |
| 982 | } |
| 983 | |
| 984 | if ($page_builder === 'elementor') { |
| 985 | $widget_id = sanitize_text_field((string) $request->get_param('widgetId')); |
| 986 | if (!$widget_id) return new WP_REST_Response(['status'=>false,'message'=>'Missing widgetId.'], 400); |
| 987 | |
| 988 | $elementor_data = atarim_elementor_get_document_data_array($post_id); |
| 989 | if (!is_array($elementor_data)) return new WP_REST_Response(['status'=>false,'message'=>'No valid _elementor_data found.'], 404); |
| 990 | |
| 991 | $widget = atarim_elementor_find_element_by_id($elementor_data, $widget_id); |
| 992 | if (!is_array($widget)) return new WP_REST_Response(['status'=>false,'message'=>'Widget not found.'], 404); |
| 993 | |
| 994 | return new WP_REST_Response([ |
| 995 | 'status' => true, |
| 996 | 'pageBuilder' => 'elementor', |
| 997 | 'postId' => $post_id, |
| 998 | 'widgetId' => $widget_id, |
| 999 | 'content' => $widget, |
| 1000 | ], 200); |
| 1001 | } |
| 1002 | |
| 1003 | if ($page_builder === 'block') { |
| 1004 | |
| 1005 | $block_id = sanitize_text_field((string) $request->get_param('blockId')); |
| 1006 | $block_name = sanitize_text_field((string) $request->get_param('blockName')); |
| 1007 | $anchor_index = (int) $request->get_param('anchorIndex'); |
| 1008 | $snippet = (string) $request->get_param('snippet'); |
| 1009 | |
| 1010 | if (trim($block_id) === '' && trim($block_name) === '') { |
| 1011 | return new WP_REST_Response(['status'=>false,'message'=>'Missing blockId or blockName.'], 400); |
| 1012 | } |
| 1013 | |
| 1014 | $match = atarim_find_gutenberg_block( |
| 1015 | $post->post_content, |
| 1016 | $block_id, |
| 1017 | $block_name, |
| 1018 | $anchor_index, |
| 1019 | $snippet |
| 1020 | ); |
| 1021 | |
| 1022 | if (!$match) { |
| 1023 | return new WP_REST_Response([ |
| 1024 | 'status'=>false, |
| 1025 | 'message'=>'Could not find matching Gutenberg block.', |
| 1026 | ], 404); |
| 1027 | } |
| 1028 | |
| 1029 | return new WP_REST_Response([ |
| 1030 | 'status' => true, |
| 1031 | 'pageBuilder' => 'block', |
| 1032 | 'postId' => $post_id, |
| 1033 | 'blockId' => $match['blockId'], |
| 1034 | 'blockName' => $block_name, |
| 1035 | 'anchorIndex' => $anchor_index, |
| 1036 | 'matchedBy' => $match['matchedBy'], // id | anchor_index | snippet |
| 1037 | 'snippet' => $snippet, |
| 1038 | 'blockPath' => $match['blockPath'], // nested like "3.0.1" |
| 1039 | 'content' => $match['serializedBlock'], // raw serialized block string |
| 1040 | ], 200); |
| 1041 | } |
| 1042 | |
| 1043 | // Classic |
| 1044 | $path_string = (string) $request->get_param('path'); |
| 1045 | $tag = strtolower((string) $request->get_param('tag')); |
| 1046 | $snippet = (string) $request->get_param('snippet'); |
| 1047 | |
| 1048 | if (trim($path_string) === '' || trim($tag) === '' || trim($snippet) === '') { |
| 1049 | return new WP_REST_Response(['status'=>false,'message'=>'Missing path, tag, or snippet.'], 400); |
| 1050 | } |
| 1051 | |
| 1052 | $steps = atarim_parse_compact_path($path_string); |
| 1053 | $found = atarim_classic_find_node_outer_html($post->post_content, $steps, $tag, $snippet); |
| 1054 | |
| 1055 | if (!$found) { |
| 1056 | return new WP_REST_Response(['status'=>false,'message'=>'Could not find matching HTML element in classic content.'], 404); |
| 1057 | } |
| 1058 | |
| 1059 | return new WP_REST_Response([ |
| 1060 | 'status' => true, |
| 1061 | 'pageBuilder' => 'classic', |
| 1062 | 'postId' => $post_id, |
| 1063 | 'path' => $path_string, |
| 1064 | 'tag' => $tag, |
| 1065 | 'snippet' => $snippet, |
| 1066 | 'content' => $found, |
| 1067 | ], 200); |
| 1068 | } |
| 1069 | |
| 1070 | /* =========================== |
| 1071 | * REST: SAVE |
| 1072 | * =========================== */ |
| 1073 | |
| 1074 | /** |
| 1075 | * Build a write-receipt for an inline save, measured from the RE-READ stored |
| 1076 | * state (never echoed back), so a caller can confirm a write actually took |
| 1077 | * effect without trusting a bare success and without re-fetching the whole |
| 1078 | * document. Addresses the "success but nothing changed" class: verified compares |
| 1079 | * what we intended to store against what is actually stored now, byte-for-byte — |
| 1080 | * so a silent transform/kses/no-op shows up as verified:false. |
| 1081 | * |
| 1082 | * @param string $intended The exact string we tried to store. |
| 1083 | * @param string $stored The string actually stored now (re-read). |
| 1084 | * @param string|null $before The stored string before the write (for changed). |
| 1085 | * @param int|null $revision_id Latest revision id, if the write created one. |
| 1086 | * @return array |
| 1087 | */ |
| 1088 | /** |
| 1089 | * Revision id holding the post's CURRENT content, captured before a Do It write |
| 1090 | * so undo has something to restore. |
| 1091 | * |
| 1092 | * wp_update_post creates its revision from the post as it now is — i.e. the NEW |
| 1093 | * content — so the revision that exists after a save is the wrong end of the |
| 1094 | * edit. Snapshotting first is what makes the returned id restorable. When |
| 1095 | * wp_save_post_revision dedupes (the latest revision already matches current |
| 1096 | * content) that existing revision is itself the pre-write state, so fall back to it. |
| 1097 | */ |
| 1098 | /** |
| 1099 | * Meta slot holding pre-write snapshots for builders whose content lives in post |
| 1100 | * meta, where WordPress creates no revision to restore. |
| 1101 | */ |
| 1102 | const ATARIM_DOIT_UNDO_META = '_avc_doit_undo'; |
| 1103 | |
| 1104 | const ATARIM_DOIT_UNDO_KEEP = 10; |
| 1105 | |
| 1106 | /** |
| 1107 | * Store the pre-write state of a meta-stored builder and return a token that |
| 1108 | * atarim_doit_undo_handler can restore. |
| 1109 | * |
| 1110 | * Block and classic saves get a real WordPress revision; Elementor and the other |
| 1111 | * meta-stored builders get nothing, so before this there was no undo for them at |
| 1112 | * all. Tokens are integers so callers can store them in the same column as a |
| 1113 | * revision id. |
| 1114 | * |
| 1115 | * @return int |
| 1116 | */ |
| 1117 | function atarim_doit_store_undo_snapshot($post_id, $type, $data, $meta_key = null) { |
| 1118 | |
| 1119 | $snapshots = get_post_meta($post_id, ATARIM_DOIT_UNDO_META, true); |
| 1120 | if (!is_array($snapshots)) { $snapshots = []; } |
| 1121 | |
| 1122 | $token = empty($snapshots) ? 1 : (max(array_keys($snapshots)) + 1); |
| 1123 | |
| 1124 | $snapshots[$token] = [ |
| 1125 | 'type' => (string) $type, |
| 1126 | 'meta_key' => $meta_key, |
| 1127 | 'data' => $data, |
| 1128 | 'time' => time(), |
| 1129 | ]; |
| 1130 | |
| 1131 | if (count($snapshots) > ATARIM_DOIT_UNDO_KEEP) { |
| 1132 | $snapshots = array_slice($snapshots, -ATARIM_DOIT_UNDO_KEEP, null, true); |
| 1133 | } |
| 1134 | |
| 1135 | update_post_meta($post_id, ATARIM_DOIT_UNDO_META, $snapshots); |
| 1136 | |
| 1137 | return (int) $token; |
| 1138 | } |
| 1139 | |
| 1140 | /** |
| 1141 | * Restore a snapshot stored by atarim_doit_store_undo_snapshot. |
| 1142 | * |
| 1143 | * @return array{restored:bool, message:string} |
| 1144 | */ |
| 1145 | function atarim_doit_restore_undo_snapshot($post_id, $token) { |
| 1146 | |
| 1147 | $snapshots = get_post_meta($post_id, ATARIM_DOIT_UNDO_META, true); |
| 1148 | if (!is_array($snapshots) || !isset($snapshots[$token])) { |
| 1149 | return ['restored' => false, 'message' => 'No snapshot with that id for this post.']; |
| 1150 | } |
| 1151 | |
| 1152 | $snapshot = $snapshots[$token]; |
| 1153 | $meta_key = $snapshot['meta_key'] ?? null; |
| 1154 | |
| 1155 | if (!is_string($meta_key) || $meta_key === '') { |
| 1156 | return ['restored' => false, 'message' => 'Snapshot is missing its target meta key.']; |
| 1157 | } |
| 1158 | |
| 1159 | update_post_meta($post_id, $meta_key, wp_slash($snapshot['data'])); |
| 1160 | atarim_doit_after_content_save($post_id, (string) ($snapshot['type'] ?? '')); |
| 1161 | |
| 1162 | unset($snapshots[$token]); |
| 1163 | update_post_meta($post_id, ATARIM_DOIT_UNDO_META, $snapshots); |
| 1164 | |
| 1165 | return ['restored' => true, 'message' => 'Snapshot restored.']; |
| 1166 | } |
| 1167 | |
| 1168 | /** |
| 1169 | * Invalidate what a page builder cached for this post after Do It rewrites it. |
| 1170 | * |
| 1171 | * Only the Elementor branch used to do this, so a Divi or WPBakery page — both of |
| 1172 | * which are detected as classic, because their shortcodes live in post_content — |
| 1173 | * would keep serving its previously rendered output after a successful write. |
| 1174 | */ |
| 1175 | function atarim_doit_after_content_save($post_id, $page_builder = '') { |
| 1176 | |
| 1177 | clean_post_cache($post_id); |
| 1178 | |
| 1179 | // Elementor caches rendered element output, page assets and generated CSS per |
| 1180 | // post. This lives here rather than in the save branch so that undo — which |
| 1181 | // rewrites the same meta — invalidates them too; without it a restore updates |
| 1182 | // the data but keeps serving the superseded render. |
| 1183 | delete_post_meta($post_id, '_elementor_element_cache'); |
| 1184 | delete_post_meta($post_id, '_elementor_page_assets'); |
| 1185 | if ( class_exists('\Elementor\Core\Files\CSS\Post') ) { |
| 1186 | try { ( new \Elementor\Core\Files\CSS\Post($post_id) )->delete(); } catch (Throwable $e) {} |
| 1187 | } |
| 1188 | |
| 1189 | // Divi keeps generated static CSS/JS per post. |
| 1190 | if (class_exists('\ET_Core_PageResource') && method_exists('\ET_Core_PageResource', 'remove_static_resources')) { |
| 1191 | try { \ET_Core_PageResource::remove_static_resources($post_id, 'all'); } catch (Throwable $e) {} |
| 1192 | } |
| 1193 | |
| 1194 | // WPBakery regenerates its per-post custom CSS from the shortcodes. |
| 1195 | if (function_exists('vc_modules_manager')) { |
| 1196 | delete_post_meta($post_id, '_wpb_shortcodes_custom_css'); |
| 1197 | } |
| 1198 | |
| 1199 | // Full-page caches, where the plugin exposes a per-URL purge. |
| 1200 | $url = get_permalink($post_id); |
| 1201 | if ($url) { |
| 1202 | if (function_exists('rocket_clean_files')) { rocket_clean_files($url); } |
| 1203 | if (function_exists('w3tc_flush_url')) { w3tc_flush_url($url); } |
| 1204 | if (function_exists('wpsc_delete_url_cache')) { wpsc_delete_url_cache($url); } |
| 1205 | } |
| 1206 | |
| 1207 | do_action('atarim_doit_content_saved', $post_id, $page_builder); |
| 1208 | } |
| 1209 | |
| 1210 | function atarim_doit_snapshot_revision($post_id) { |
| 1211 | |
| 1212 | $revision_id = wp_save_post_revision($post_id); |
| 1213 | if ($revision_id) return (int) $revision_id; |
| 1214 | |
| 1215 | $revs = wp_get_post_revisions($post_id, ['numberposts' => 1, 'fields' => 'ids']); |
| 1216 | return $revs ? (int) reset($revs) : null; |
| 1217 | } |
| 1218 | |
| 1219 | function atarim_inline_save_receipt( $intended, $stored, $before = null, $revision_id = null ) { |
| 1220 | $intended = is_string( $intended ) ? $intended : (string) wp_json_encode( $intended ); |
| 1221 | $stored = is_string( $stored ) ? $stored : (string) wp_json_encode( $stored ); |
| 1222 | |
| 1223 | $receipt = [ |
| 1224 | 'verified' => ( sha1( $intended ) === sha1( $stored ) ), |
| 1225 | 'storedBytes' => strlen( $stored ), |
| 1226 | 'storedSha1' => sha1( $stored ), |
| 1227 | ]; |
| 1228 | |
| 1229 | if ( $before !== null ) { |
| 1230 | $before = is_string( $before ) ? $before : (string) wp_json_encode( $before ); |
| 1231 | $receipt['changed'] = ( sha1( $before ) !== sha1( $stored ) ); |
| 1232 | } |
| 1233 | if ( $revision_id !== null ) { |
| 1234 | $receipt['revisionId'] = (int) $revision_id; |
| 1235 | } |
| 1236 | |
| 1237 | return $receipt; |
| 1238 | } |
| 1239 | |
| 1240 | function atarim_inline_save_handler(WP_REST_Request $request) { |
| 1241 | |
| 1242 | $post_id = absint($request->get_param('postId')); |
| 1243 | $page_builder = sanitize_text_field((string) $request->get_param('pageBuilder')); |
| 1244 | |
| 1245 | if (!$post_id) { |
| 1246 | return new WP_REST_Response(['status' => false, 'message' => 'Missing postId.'], 400); |
| 1247 | } |
| 1248 | |
| 1249 | // Post-title target: update post_title and (optionally) the slug, independent |
| 1250 | // of the page builder. |
| 1251 | if ($request->get_param('target') === 'post_title') { |
| 1252 | $post = get_post($post_id); |
| 1253 | if (!$post) return new WP_REST_Response(['status'=>false,'message'=>'Post not found.'], 404); |
| 1254 | |
| 1255 | $new_title = trim((string) $request->get_param('title')); |
| 1256 | if ($new_title === '') { |
| 1257 | return new WP_REST_Response(['status'=>false,'message'=>'Missing title to save.'], 400); |
| 1258 | } |
| 1259 | |
| 1260 | $old_title = $post->post_title; |
| 1261 | $old_slug = $post->post_name; |
| 1262 | |
| 1263 | $update = [ |
| 1264 | 'ID' => $post_id, |
| 1265 | 'post_title' => $new_title, // wp_update_post sanitises |
| 1266 | ]; |
| 1267 | |
| 1268 | // Slug is optional: only touched when updateSlug is truthy. When on with |
| 1269 | // no explicit slug, regenerate a unique slug from the new title. |
| 1270 | $update_slug = filter_var($request->get_param('updateSlug'), FILTER_VALIDATE_BOOLEAN); |
| 1271 | $new_slug = $old_slug; |
| 1272 | if ($update_slug) { |
| 1273 | $explicit = sanitize_title((string) $request->get_param('slug')); |
| 1274 | $desired = $explicit !== '' ? $explicit : sanitize_title($new_title); |
| 1275 | if ($desired === '') { $desired = $old_slug; } |
| 1276 | $new_slug = wp_unique_post_slug($desired, $post_id, $post->post_status, $post->post_type, $post->post_parent); |
| 1277 | $update['post_name'] = $new_slug; |
| 1278 | } |
| 1279 | |
| 1280 | $result = wp_update_post(wp_slash($update), true); |
| 1281 | if (is_wp_error($result)) { |
| 1282 | return new WP_REST_Response(['status'=>false,'message'=>'Failed to update title: ' . $result->get_error_message()], 500); |
| 1283 | } |
| 1284 | |
| 1285 | $saved = get_post($post_id); |
| 1286 | $final_slug = $saved ? $saved->post_name : $new_slug; |
| 1287 | |
| 1288 | return new WP_REST_Response([ |
| 1289 | 'status' => true, |
| 1290 | 'target' => 'post_title', |
| 1291 | 'postId' => $post_id, |
| 1292 | 'oldTitle' => $old_title, |
| 1293 | 'title' => $saved ? $saved->post_title : $new_title, |
| 1294 | 'slugUpdated' => ($final_slug !== $old_slug), |
| 1295 | 'oldSlug' => $old_slug, |
| 1296 | 'slug' => $final_slug, |
| 1297 | ], 200); |
| 1298 | } |
| 1299 | |
| 1300 | if (atarim_doit_builder_is_derived($page_builder)) { |
| 1301 | return new WP_REST_Response([ |
| 1302 | 'status' => false, |
| 1303 | 'message' => sprintf( |
| 1304 | 'This page uses %s, which regenerates the page from its own stored copy. An edit made here would be discarded the next time the page is saved in the builder, so Do It will not write to it.', |
| 1305 | $page_builder === 'vc' ? 'Visual Composer' : 'Brizy' |
| 1306 | ), |
| 1307 | ], 400); |
| 1308 | } |
| 1309 | |
| 1310 | if (!in_array($page_builder, ['elementor', 'beaver', 'siteorigin', 'block', 'classic'], true)) { |
| 1311 | return new WP_REST_Response([ |
| 1312 | 'status' => false, |
| 1313 | 'message' => 'This page builder is not supported yet.', |
| 1314 | ], 400); |
| 1315 | } |
| 1316 | |
| 1317 | $post = get_post($post_id); |
| 1318 | if (!$post) return new WP_REST_Response(['status'=>false,'message'=>'Post not found.'], 404); |
| 1319 | |
| 1320 | if ($page_builder === 'beaver' || $page_builder === 'siteorigin') { |
| 1321 | return atarim_doit_save_meta_builder($request, $post_id, $page_builder); |
| 1322 | } |
| 1323 | |
| 1324 | if ($page_builder === 'elementor') { |
| 1325 | $widget_id = sanitize_text_field((string) $request->get_param('widgetId')); |
| 1326 | $widget = $request->get_param('content'); |
| 1327 | |
| 1328 | if (!$widget_id) return new WP_REST_Response(['status'=>false,'message'=>'Missing widgetId.'], 400); |
| 1329 | if (!is_array($widget)) return new WP_REST_Response(['status'=>false,'message'=>'Elementor content must be a JSON object.'], 400); |
| 1330 | |
| 1331 | if (empty($widget['id']) || (string)$widget['id'] !== (string)$widget_id) { |
| 1332 | return new WP_REST_Response(['status'=>false,'message'=>'content.id must match widgetId.'], 400); |
| 1333 | } |
| 1334 | |
| 1335 | $elementor_data = atarim_elementor_get_document_data_array($post_id); |
| 1336 | if (!is_array($elementor_data)) return new WP_REST_Response(['status'=>false,'message'=>'No valid _elementor_data found.'], 404); |
| 1337 | |
| 1338 | $replaced = false; |
| 1339 | $updated_data = atarim_elementor_replace_element_by_id($elementor_data, $widget_id, $widget, $replaced); |
| 1340 | if (!$replaced) return new WP_REST_Response(['status'=>false,'message'=>'Widget not found; nothing saved.'], 404); |
| 1341 | |
| 1342 | $intended_json = wp_json_encode($updated_data); |
| 1343 | |
| 1344 | // Elementor writes post meta, so WordPress creates no revision. Snapshot |
| 1345 | // the previous data ourselves or this edit would have no undo at all. |
| 1346 | $before_json = get_post_meta($post_id, '_elementor_data', true); |
| 1347 | if ( ! is_string($before_json) ) { $before_json = (string) wp_json_encode($before_json); } |
| 1348 | $undo_token = atarim_doit_store_undo_snapshot($post_id, 'elementor', $before_json, '_elementor_data'); |
| 1349 | |
| 1350 | update_post_meta($post_id, '_elementor_data', wp_slash($intended_json)); |
| 1351 | |
| 1352 | atarim_doit_after_content_save($post_id, 'elementor'); |
| 1353 | |
| 1354 | $stored_json = get_post_meta($post_id, '_elementor_data', true); |
| 1355 | if ( ! is_string($stored_json) ) { $stored_json = (string) wp_json_encode($stored_json); } |
| 1356 | $receipt = atarim_inline_save_receipt( $intended_json, $stored_json, wp_json_encode($elementor_data) ); |
| 1357 | $receipt['revisionId'] = $undo_token; |
| 1358 | $receipt['undoKind'] = 'snapshot'; |
| 1359 | |
| 1360 | return new WP_REST_Response(array_merge(['status'=>true, 'target'=>'elementor', 'widgetId'=>$widget_id], $receipt), 200); |
| 1361 | } |
| 1362 | |
| 1363 | $content = (string) $request->get_param('content'); |
| 1364 | $element_html = (string) $request->get_param('elementHtml'); |
| 1365 | |
| 1366 | if (trim($content) === '' && trim($element_html) === '') { |
| 1367 | return new WP_REST_Response(['status'=>false,'message'=>'Missing content to save.'], 400); |
| 1368 | } |
| 1369 | |
| 1370 | if ($page_builder === 'block') { |
| 1371 | $block_path = (string) $request->get_param('blockPath'); |
| 1372 | $expected_block_name = sanitize_text_field((string) $request->get_param('blockName')); |
| 1373 | $block_id = sanitize_text_field((string) $request->get_param('blockId')); |
| 1374 | |
| 1375 | // blockId resolves to a path HERE, inside the write request, rather than |
| 1376 | // being carried over from an earlier GET. That closes the read-to-write |
| 1377 | // window in which another save could shift every index. |
| 1378 | if (trim($block_id) !== '') { |
| 1379 | $resolved = atarim_find_gutenberg_block($post->post_content, $block_id, $expected_block_name, -1, ''); |
| 1380 | if ($resolved === null) { |
| 1381 | return new WP_REST_Response([ |
| 1382 | 'status'=>false, |
| 1383 | 'message'=>'No block with that blockId; it may have been deleted.', |
| 1384 | ], 404); |
| 1385 | } |
| 1386 | $block_path = $resolved['blockPath']; |
| 1387 | $parsed_existing = parse_blocks($resolved['serializedBlock']); |
| 1388 | if (trim($expected_block_name) === '' && isset($parsed_existing[0]['blockName'])) { |
| 1389 | $expected_block_name = (string) $parsed_existing[0]['blockName']; |
| 1390 | } |
| 1391 | } |
| 1392 | |
| 1393 | if (trim($block_path) === '') { |
| 1394 | return new WP_REST_Response(['status'=>false,'message'=>'Missing blockPath or blockId.'], 400); |
| 1395 | } |
| 1396 | if (trim($expected_block_name) === '') { |
| 1397 | return new WP_REST_Response(['status'=>false,'message'=>'Missing blockName.'], 400); |
| 1398 | } |
| 1399 | |
| 1400 | // elementHtml carries just the edited element; splice it into the canonical |
| 1401 | // block here so the caller does not have to reimplement block-aware |
| 1402 | // splicing outside WordPress. |
| 1403 | if (trim($content) === '') { |
| 1404 | $spliced = atarim_splice_element_into_block($post->post_content, $block_path, $element_html); |
| 1405 | if ($spliced === null) { |
| 1406 | return new WP_REST_Response([ |
| 1407 | 'status'=>false, |
| 1408 | 'message'=>'Cannot splice elementHtml into this block (it has inner blocks). Send full block markup as content instead.', |
| 1409 | ], 400); |
| 1410 | } |
| 1411 | $content = $spliced; |
| 1412 | } |
| 1413 | |
| 1414 | // Validate: new content parses as a single block of the expected type |
| 1415 | $parsed_new = parse_blocks($content); |
| 1416 | if (!is_array($parsed_new) || empty($parsed_new) || !is_array($parsed_new[0])) { |
| 1417 | return new WP_REST_Response([ |
| 1418 | 'status'=>false, |
| 1419 | 'message'=>'Content is not a valid block.', |
| 1420 | ], 400); |
| 1421 | } |
| 1422 | if (($parsed_new[0]['blockName'] ?? '') !== $expected_block_name) { |
| 1423 | return new WP_REST_Response([ |
| 1424 | 'status'=>false, |
| 1425 | 'message'=>'Content block type does not match expected blockName.', |
| 1426 | ], 400); |
| 1427 | } |
| 1428 | |
| 1429 | // Validate: existing block at blockPath is the expected type (stale-edit guard) |
| 1430 | $existing_blocks = parse_blocks($post->post_content); |
| 1431 | $path_parts = array_values(array_filter( |
| 1432 | explode('.', trim($block_path)), |
| 1433 | static function($v) { return $v !== ''; } |
| 1434 | )); |
| 1435 | $existing_block = atarim_get_block_ref_by_path($existing_blocks, $path_parts); |
| 1436 | if (!is_array($existing_block) || ($existing_block['blockName'] ?? '') !== $expected_block_name) { |
| 1437 | return new WP_REST_Response([ |
| 1438 | 'status'=>false, |
| 1439 | 'message'=>'Block at path no longer matches expected type. The post may have been edited elsewhere; please refresh.', |
| 1440 | ], 409); |
| 1441 | } |
| 1442 | |
| 1443 | $updated = atarim_replace_gutenberg_block_by_nested_path($post->post_content, $block_path, $content); |
| 1444 | if ($updated === null) { |
| 1445 | return new WP_REST_Response([ |
| 1446 | 'status'=>false, |
| 1447 | 'message'=>'Could not replace Gutenberg block (path not found or invalid replacement).', |
| 1448 | ], 404); |
| 1449 | } |
| 1450 | |
| 1451 | $undo_revision = atarim_doit_snapshot_revision($post_id); |
| 1452 | |
| 1453 | wp_update_post([ |
| 1454 | 'ID' => $post_id, |
| 1455 | 'post_content' => $updated, |
| 1456 | ]); |
| 1457 | |
| 1458 | atarim_doit_after_content_save($post_id, 'block'); |
| 1459 | |
| 1460 | $stored_content = (string) get_post_field('post_content', $post_id); |
| 1461 | $receipt = atarim_inline_save_receipt( $updated, $stored_content, $post->post_content, $undo_revision ); |
| 1462 | $receipt['undoKind'] = 'revision'; |
| 1463 | |
| 1464 | return new WP_REST_Response(array_merge(['status'=>true, 'target'=>'block'], $receipt), 200); |
| 1465 | } |
| 1466 | |
| 1467 | // Classic save |
| 1468 | $path_string = (string) $request->get_param('path'); |
| 1469 | $tag = strtolower((string) $request->get_param('tag')); |
| 1470 | $snippet = (string) $request->get_param('snippet'); |
| 1471 | |
| 1472 | if (trim($path_string) === '' || trim($tag) === '' || trim($snippet) === '') { |
| 1473 | return new WP_REST_Response(['status'=>false,'message'=>'Missing path/tag/snippet for classic save.'], 400); |
| 1474 | } |
| 1475 | |
| 1476 | $steps = atarim_parse_compact_path($path_string); |
| 1477 | |
| 1478 | $new_post_content = atarim_classic_replace_node_outer_html($post->post_content, $steps, $tag, $snippet, $content); |
| 1479 | if ($new_post_content === null) { |
| 1480 | return new WP_REST_Response(['status'=>false,'message'=>'Could not find matching element to replace in classic content.'], 404); |
| 1481 | } |
| 1482 | |
| 1483 | $undo_revision = atarim_doit_snapshot_revision($post_id); |
| 1484 | |
| 1485 | wp_update_post([ |
| 1486 | 'ID' => $post_id, |
| 1487 | 'post_content' => $new_post_content, |
| 1488 | ]); |
| 1489 | |
| 1490 | atarim_doit_after_content_save($post_id, 'classic'); |
| 1491 | |
| 1492 | $stored_content = (string) get_post_field('post_content', $post_id); |
| 1493 | $receipt = atarim_inline_save_receipt( $new_post_content, $stored_content, $post->post_content, $undo_revision ); |
| 1494 | $receipt['undoKind'] = 'revision'; |
| 1495 | |
| 1496 | return new WP_REST_Response(array_merge(['status'=>true, 'target'=>'classic'], $receipt), 200); |
| 1497 | } |
| 1498 | |
| 1499 | /* =========================== |
| 1500 | * Shared helpers: compact path (classic only) |
| 1501 | * =========================== */ |
| 1502 | |
| 1503 | function atarim_parse_compact_path(string $path): array { |
| 1504 | $path = trim($path); |
| 1505 | if ($path === '') return []; |
| 1506 | |
| 1507 | $parts = array_map('trim', explode('>', $path)); |
| 1508 | $steps = []; |
| 1509 | |
| 1510 | foreach ($parts as $part) { |
| 1511 | $part = trim($part); |
| 1512 | if ($part === '') continue; |
| 1513 | |
| 1514 | if (preg_match('/^([a-z0-9]+)(?:\((\d+)\))?$/i', $part, $m)) { |
| 1515 | $steps[] = [ |
| 1516 | 'tag' => strtoupper($m[1]), |
| 1517 | 'index' => isset($m[2]) ? (int) $m[2] : 0, |
| 1518 | ]; |
| 1519 | } |
| 1520 | } |
| 1521 | |
| 1522 | return $steps; |
| 1523 | } |
| 1524 | |
| 1525 | /* =========================== |
| 1526 | * Elementor helpers |
| 1527 | * =========================== */ |
| 1528 | |
| 1529 | function atarim_elementor_get_document_data_array(int $post_id): ?array { |
| 1530 | $raw = get_post_meta($post_id, '_elementor_data', true); |
| 1531 | if (empty($raw)) return null; |
| 1532 | |
| 1533 | if (is_string($raw)) { |
| 1534 | $decoded = json_decode($raw, true); |
| 1535 | return is_array($decoded) ? $decoded : null; |
| 1536 | } |
| 1537 | |
| 1538 | return is_array($raw) ? $raw : null; |
| 1539 | } |
| 1540 | |
| 1541 | function atarim_elementor_find_element_by_id(array $nodes, string $target_id): ?array { |
| 1542 | foreach ($nodes as $node) { |
| 1543 | if (!is_array($node)) continue; |
| 1544 | |
| 1545 | if (isset($node['id']) && (string)$node['id'] === (string)$target_id) { |
| 1546 | return $node; |
| 1547 | } |
| 1548 | |
| 1549 | if (isset($node['elements']) && is_array($node['elements'])) { |
| 1550 | $found = atarim_elementor_find_element_by_id($node['elements'], $target_id); |
| 1551 | if ($found !== null) return $found; |
| 1552 | } |
| 1553 | } |
| 1554 | return null; |
| 1555 | } |
| 1556 | |
| 1557 | function atarim_elementor_replace_element_by_id(array $nodes, string $target_id, array $replacement_node, bool &$replaced): array { |
| 1558 | foreach ($nodes as $index => $node) { |
| 1559 | if (!is_array($node)) continue; |
| 1560 | |
| 1561 | if (isset($node['id']) && (string)$node['id'] === (string)$target_id) { |
| 1562 | $nodes[$index] = $replacement_node; |
| 1563 | $replaced = true; |
| 1564 | return $nodes; |
| 1565 | } |
| 1566 | |
| 1567 | if (isset($node['elements']) && is_array($node['elements'])) { |
| 1568 | $nodes[$index]['elements'] = atarim_elementor_replace_element_by_id( |
| 1569 | $node['elements'], |
| 1570 | $target_id, |
| 1571 | $replacement_node, |
| 1572 | $replaced |
| 1573 | ); |
| 1574 | if ($replaced) return $nodes; |
| 1575 | } |
| 1576 | } |
| 1577 | return $nodes; |
| 1578 | } |
| 1579 | |
| 1580 | /* =========================== |
| 1581 | * Gutenberg helpers (blockName + anchorIndex + nested path replace) |
| 1582 | * =========================== */ |
| 1583 | |
| 1584 | function atarim_normalize_text(string $text): string { |
| 1585 | $text = wp_strip_all_tags($text); |
| 1586 | $text = preg_replace('/\s+/u', ' ', $text); |
| 1587 | return strtolower(trim($text)); |
| 1588 | } |
| 1589 | |
| 1590 | function atarim_collect_matching_blocks(array $blocks, string $block_name, array &$out, string $parent_path = ''): void { |
| 1591 | foreach ($blocks as $i => $block) { |
| 1592 | if (!is_array($block)) continue; |
| 1593 | |
| 1594 | $current_block_name = (string)($block['blockName'] ?? ''); |
| 1595 | $path = ($parent_path === '') ? (string)$i : ($parent_path . '.' . $i); |
| 1596 | |
| 1597 | if ($current_block_name === $block_name) { |
| 1598 | $out[] = ['path' => $path, 'block' => $block]; |
| 1599 | } |
| 1600 | |
| 1601 | if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) { |
| 1602 | atarim_collect_matching_blocks($block['innerBlocks'], $block_name, $out, $path); |
| 1603 | } |
| 1604 | } |
| 1605 | } |
| 1606 | |
| 1607 | /** |
| 1608 | * Resolve a Gutenberg block, preferring durable identity over position. |
| 1609 | * |
| 1610 | * 1. blockId (metadata.avcBlockId) — survives reordering, insertion, deletion and |
| 1611 | * Gutenberg edits. The only mode that is correct on a block theme, where the |
| 1612 | * render-order anchor_index counts header/footer template blocks that |
| 1613 | * post_content does not contain. |
| 1614 | * 2. anchor_index + snippet — the legacy path, kept for unstamped blocks. |
| 1615 | * 3. snippet search — when the index misses, look for the one block of this name |
| 1616 | * whose rendered text contains the snippet. Without this a page edit turns |
| 1617 | * every stale task into a hard failure. |
| 1618 | * |
| 1619 | * @return array{blockPath:string,serializedBlock:string,blockId:string,matchedBy:string}|null |
| 1620 | */ |
| 1621 | function atarim_find_gutenberg_block(string $post_content, string $block_id, string $block_name, int $anchor_index, string $snippet): ?array { |
| 1622 | |
| 1623 | if (trim($block_id) !== '' && class_exists('AVCF_Gutenberg_Helpers')) { |
| 1624 | $blocks = AVCF_Gutenberg_Helpers::parse_raw($post_content); |
| 1625 | $hit = AVCF_Gutenberg_Helpers::raw_find_by_id($blocks, $block_id); |
| 1626 | if ($hit !== null) { |
| 1627 | return [ |
| 1628 | // raw_find_by_id builds slash paths ("6/0"); the save path splits |
| 1629 | // on dots. Both index the same parse_blocks arrays. |
| 1630 | 'blockPath' => str_replace('/', '.', $hit['path']), |
| 1631 | 'serializedBlock' => serialize_block($hit['block']), |
| 1632 | 'blockId' => $block_id, |
| 1633 | 'matchedBy' => 'id', |
| 1634 | ]; |
| 1635 | } |
| 1636 | } |
| 1637 | |
| 1638 | if (trim($block_name) === '') return null; |
| 1639 | |
| 1640 | if ($anchor_index >= 0) { |
| 1641 | $match = atarim_find_gutenberg_block_by_anchor_index($post_content, $block_name, $anchor_index, $snippet); |
| 1642 | if ($match !== null) { |
| 1643 | $match['matchedBy'] = 'anchor_index'; |
| 1644 | return $match; |
| 1645 | } |
| 1646 | } |
| 1647 | |
| 1648 | return atarim_find_gutenberg_block_by_snippet($post_content, $block_name, $snippet); |
| 1649 | } |
| 1650 | |
| 1651 | /** |
| 1652 | * Last-resort lookup: the single block of this name whose rendered text contains |
| 1653 | * the snippet. Ambiguous matches are refused — writing to the wrong block is far |
| 1654 | * worse than failing. |
| 1655 | * |
| 1656 | * @return array{blockPath:string,serializedBlock:string,blockId:string,matchedBy:string}|null |
| 1657 | */ |
| 1658 | function atarim_find_gutenberg_block_by_snippet(string $post_content, string $block_name, string $snippet): ?array { |
| 1659 | |
| 1660 | $snippet_norm = atarim_normalize_text($snippet); |
| 1661 | if ($snippet_norm === '') return null; |
| 1662 | |
| 1663 | $matches = []; |
| 1664 | atarim_collect_matching_blocks(parse_blocks($post_content), $block_name, $matches); |
| 1665 | |
| 1666 | $hits = []; |
| 1667 | foreach ($matches as $m) { |
| 1668 | try { $rendered = render_block($m['block']); } catch (Throwable $e) { $rendered = serialize_block($m['block']); } |
| 1669 | if (str_contains(atarim_normalize_text($rendered), $snippet_norm)) { |
| 1670 | $hits[] = $m; |
| 1671 | } |
| 1672 | } |
| 1673 | |
| 1674 | if (count($hits) !== 1) return null; |
| 1675 | |
| 1676 | $block = $hits[0]['block']; |
| 1677 | return [ |
| 1678 | 'blockPath' => $hits[0]['path'], |
| 1679 | 'serializedBlock' => serialize_block($block), |
| 1680 | 'blockId' => class_exists('AVCF_Gutenberg_Helpers') |
| 1681 | ? AVCF_Gutenberg_Helpers::extract_id($block['attrs'] ?? []) |
| 1682 | : '', |
| 1683 | 'matchedBy' => 'snippet', |
| 1684 | ]; |
| 1685 | } |
| 1686 | |
| 1687 | function atarim_find_gutenberg_block_by_anchor_index(string $post_content, string $block_name, int $anchor_index, string $snippet): ?array { |
| 1688 | $blocks = parse_blocks($post_content); |
| 1689 | if (!is_array($blocks)) return null; |
| 1690 | |
| 1691 | $matches = []; |
| 1692 | atarim_collect_matching_blocks($blocks, $block_name, $matches); |
| 1693 | |
| 1694 | if (!isset($matches[$anchor_index])) return null; |
| 1695 | |
| 1696 | $picked = $matches[$anchor_index]['block']; |
| 1697 | $picked_path = $matches[$anchor_index]['path']; |
| 1698 | |
| 1699 | $snippet_norm = atarim_normalize_text($snippet); |
| 1700 | |
| 1701 | if ($snippet_norm !== '') { |
| 1702 | $rendered = ''; |
| 1703 | try { $rendered = render_block($picked); } catch (Throwable $e) { $rendered = serialize_block($picked); } |
| 1704 | |
| 1705 | if (!str_contains(atarim_normalize_text($rendered), $snippet_norm)) { |
| 1706 | return null; |
| 1707 | } |
| 1708 | } |
| 1709 | |
| 1710 | return [ |
| 1711 | 'blockPath' => $picked_path, |
| 1712 | 'serializedBlock' => serialize_block($picked), |
| 1713 | 'blockId' => class_exists('AVCF_Gutenberg_Helpers') |
| 1714 | ? AVCF_Gutenberg_Helpers::extract_id($picked['attrs'] ?? []) |
| 1715 | : '', |
| 1716 | ]; |
| 1717 | } |
| 1718 | |
| 1719 | function atarim_get_block_ref_by_path(array &$blocks, array $path_parts) { |
| 1720 | $ref = &$blocks; |
| 1721 | foreach ($path_parts as $part_index => $part) { |
| 1722 | $idx = (int)$part; |
| 1723 | if (!isset($ref[$idx]) || !is_array($ref[$idx])) return null; |
| 1724 | |
| 1725 | if ($part_index === count($path_parts) - 1) { |
| 1726 | return $ref[$idx]; |
| 1727 | } |
| 1728 | |
| 1729 | if (!isset($ref[$idx]['innerBlocks']) || !is_array($ref[$idx]['innerBlocks'])) return null; |
| 1730 | $ref = &$ref[$idx]['innerBlocks']; |
| 1731 | } |
| 1732 | return null; |
| 1733 | } |
| 1734 | |
| 1735 | /** |
| 1736 | * Rebuild a leaf block's serialized markup with $element_html as its body, |
| 1737 | * preserving the block name and every attribute (so the comment JSON and the |
| 1738 | * inline HTML stay in sync). |
| 1739 | * |
| 1740 | * Leaf blocks only: a block with innerBlocks has structure that cannot be |
| 1741 | * inferred from a single element, and the caller must send full block markup. |
| 1742 | */ |
| 1743 | function atarim_splice_element_into_block(string $post_content, string $block_path, string $element_html): ?string { |
| 1744 | |
| 1745 | if (trim($element_html) === '') return null; |
| 1746 | |
| 1747 | $blocks = parse_blocks($post_content); |
| 1748 | $parts = array_values(array_filter( |
| 1749 | explode('.', trim($block_path)), |
| 1750 | static function($v) { return $v !== ''; } |
| 1751 | )); |
| 1752 | if (empty($parts)) return null; |
| 1753 | |
| 1754 | $block = atarim_get_block_ref_by_path($blocks, $parts); |
| 1755 | if (!is_array($block) || empty($block['blockName'])) return null; |
| 1756 | if (!empty($block['innerBlocks'])) return null; |
| 1757 | |
| 1758 | $block['innerHTML'] = $element_html; |
| 1759 | $block['innerContent'] = [$element_html]; |
| 1760 | |
| 1761 | return serialize_block($block); |
| 1762 | } |
| 1763 | |
| 1764 | function atarim_replace_gutenberg_block_by_nested_path(string $post_content, string $block_path, string $new_serialized_block): ?string { |
| 1765 | |
| 1766 | $blocks = parse_blocks($post_content); |
| 1767 | if (!is_array($blocks)) return null; |
| 1768 | |
| 1769 | $replacement_blocks = parse_blocks($new_serialized_block); |
| 1770 | if (!is_array($replacement_blocks) || empty($replacement_blocks) || !is_array($replacement_blocks[0])) { |
| 1771 | return null; |
| 1772 | } |
| 1773 | $replacement_block = $replacement_blocks[0]; |
| 1774 | |
| 1775 | $parts = array_filter(explode('.', trim($block_path)), static function($v) { return $v !== ''; }); |
| 1776 | if (empty($parts)) return null; |
| 1777 | |
| 1778 | $ref = &$blocks; |
| 1779 | |
| 1780 | for ($i = 0; $i < count($parts) - 1; $i++) { |
| 1781 | $idx = (int)$parts[$i]; |
| 1782 | if (!isset($ref[$idx]) || !is_array($ref[$idx])) return null; |
| 1783 | |
| 1784 | if (!isset($ref[$idx]['innerBlocks']) || !is_array($ref[$idx]['innerBlocks'])) { |
| 1785 | return null; |
| 1786 | } |
| 1787 | |
| 1788 | $ref = &$ref[$idx]['innerBlocks']; |
| 1789 | } |
| 1790 | |
| 1791 | $target_index = (int)$parts[count($parts) - 1]; |
| 1792 | if (!isset($ref[$target_index]) || !is_array($ref[$target_index])) return null; |
| 1793 | |
| 1794 | $ref[$target_index] = $replacement_block; |
| 1795 | |
| 1796 | return serialize_blocks($blocks); |
| 1797 | } |
| 1798 | |
| 1799 | /* =========================== |
| 1800 | * Classic helpers (DOM path) |
| 1801 | * =========================== */ |
| 1802 | |
| 1803 | function atarim_dom_load_fragment(string $html, string $wrap_id): array { |
| 1804 | $dom = new DOMDocument(); |
| 1805 | $encoded = function_exists('mb_convert_encoding') |
| 1806 | ? mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8') |
| 1807 | : $html; |
| 1808 | |
| 1809 | libxml_use_internal_errors(true); |
| 1810 | $dom->loadHTML('<div id="'.$wrap_id.'">'.$encoded.'</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); |
| 1811 | libxml_clear_errors(); |
| 1812 | |
| 1813 | $wrap = $dom->getElementById($wrap_id); |
| 1814 | return [$dom, $wrap]; |
| 1815 | } |
| 1816 | |
| 1817 | function atarim_dom_inner_html(DOMDocument $dom, DOMElement $wrap): string { |
| 1818 | $out = ''; |
| 1819 | foreach ($wrap->childNodes as $child) { |
| 1820 | $out .= $dom->saveHTML($child); |
| 1821 | } |
| 1822 | return html_entity_decode($out, ENT_QUOTES | ENT_HTML5, 'UTF-8'); |
| 1823 | } |
| 1824 | |
| 1825 | function atarim_dom_outer_html(DOMDocument $dom, DOMNode $node): string { |
| 1826 | return html_entity_decode($dom->saveHTML($node), ENT_QUOTES | ENT_HTML5, 'UTF-8'); |
| 1827 | } |
| 1828 | |
| 1829 | function atarim_dom_children_by_tag(DOMNode $node, string $tag_upper): array { |
| 1830 | $children = []; |
| 1831 | foreach ($node->childNodes as $child) { |
| 1832 | if ($child->nodeType === XML_ELEMENT_NODE && strtoupper($child->nodeName) === $tag_upper) { |
| 1833 | $children[] = $child; |
| 1834 | } |
| 1835 | } |
| 1836 | return $children; |
| 1837 | } |
| 1838 | |
| 1839 | function atarim_classic_find_node_outer_html(string $post_content, array $steps, string $tag_lower, string $snippet): ?string { |
| 1840 | [$dom, $wrap] = atarim_dom_load_fragment($post_content, '__wrap__'); |
| 1841 | if (!$wrap) return null; |
| 1842 | |
| 1843 | $current = $wrap; |
| 1844 | |
| 1845 | foreach ($steps as $step) { |
| 1846 | $children = atarim_dom_children_by_tag($current, $step['tag']); |
| 1847 | $index = (int) $step['index']; |
| 1848 | if (!isset($children[$index])) return null; |
| 1849 | $current = $children[$index]; |
| 1850 | } |
| 1851 | |
| 1852 | if (strtolower($current->nodeName) !== strtolower($tag_lower)) return null; |
| 1853 | |
| 1854 | $node_text = atarim_normalize_text($current->textContent ?? ''); |
| 1855 | $snippet_norm = atarim_normalize_text($snippet); |
| 1856 | if ($snippet_norm === '' || !str_contains($node_text, $snippet_norm)) return null; |
| 1857 | |
| 1858 | return atarim_dom_outer_html($dom, $current); |
| 1859 | } |
| 1860 | |
| 1861 | function atarim_classic_replace_node_outer_html(string $post_content, array $steps, string $tag_lower, string $snippet, string $replacement_html): ?string { |
| 1862 | [$dom, $wrap] = atarim_dom_load_fragment($post_content, '__wrap__'); |
| 1863 | if (!$wrap) return null; |
| 1864 | |
| 1865 | $current = $wrap; |
| 1866 | |
| 1867 | foreach ($steps as $step) { |
| 1868 | $children = atarim_dom_children_by_tag($current, $step['tag']); |
| 1869 | $index = (int) $step['index']; |
| 1870 | if (!isset($children[$index])) return null; |
| 1871 | $current = $children[$index]; |
| 1872 | } |
| 1873 | |
| 1874 | if (strtolower($current->nodeName) !== strtolower($tag_lower)) return null; |
| 1875 | |
| 1876 | $node_text = atarim_normalize_text($current->textContent ?? ''); |
| 1877 | $snippet_norm = atarim_normalize_text($snippet); |
| 1878 | if ($snippet_norm === '' || !str_contains($node_text, $snippet_norm)) return null; |
| 1879 | |
| 1880 | [$tmp_dom, $tmp_wrap] = atarim_dom_load_fragment($replacement_html, '__frag__'); |
| 1881 | if (!$tmp_wrap) return null; |
| 1882 | |
| 1883 | $parent = $current->parentNode; |
| 1884 | if (!$parent) return null; |
| 1885 | |
| 1886 | foreach (iterator_to_array($tmp_wrap->childNodes) as $child) { |
| 1887 | $parent->insertBefore($dom->importNode($child, true), $current); |
| 1888 | } |
| 1889 | |
| 1890 | $parent->removeChild($current); |
| 1891 | |
| 1892 | return atarim_dom_inner_html($dom, $wrap); |
| 1893 | } |
| 1894 | |
| 1895 | /* =========================== |
| 1896 | * REST: MEDIA IMPORT |
| 1897 | * Push external file URLs (e.g. task/comment attachments) into the media |
| 1898 | * library, unattached. Batch, with per-item error isolation so one bad file |
| 1899 | * does not fail the rest. |
| 1900 | * =========================== */ |
| 1901 | function atarim_inline_media_import_handler(WP_REST_Request $request) { |
| 1902 | |
| 1903 | $items = $request->get_param('items'); |
| 1904 | |
| 1905 | // Be lenient: accept a bare url string, or a single { url } / { base64 } object. |
| 1906 | if ( is_string($items) ) { |
| 1907 | $items = [ [ 'url' => $items ] ]; |
| 1908 | } elseif ( is_array($items) && ( isset($items['url']) || isset($items['base64']) ) ) { |
| 1909 | $items = [ $items ]; |
| 1910 | } |
| 1911 | |
| 1912 | if ( ! is_array($items) || empty($items) ) { |
| 1913 | return new WP_REST_Response(['status'=>false,'message'=>'Missing items: expected a non-empty array of { url | base64 }.'], 400); |
| 1914 | } |
| 1915 | |
| 1916 | if ( ! function_exists('media_handle_sideload') ) { |
| 1917 | require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 1918 | require_once ABSPATH . 'wp-admin/includes/media.php'; |
| 1919 | require_once ABSPATH . 'wp-admin/includes/image.php'; |
| 1920 | } |
| 1921 | |
| 1922 | $results = []; |
| 1923 | |
| 1924 | foreach ( $items as $item ) { |
| 1925 | if ( ! is_array($item) ) { |
| 1926 | $results[] = [ 'source' => '', 'success' => false, 'error' => 'Invalid item (expected an object with url or base64).' ]; |
| 1927 | continue; |
| 1928 | } |
| 1929 | |
| 1930 | $has_url = isset($item['url']) && trim( (string) $item['url'] ) !== ''; |
| 1931 | $has_b64 = isset($item['base64']) && trim( (string) $item['base64'] ) !== ''; |
| 1932 | |
| 1933 | if ( ! $has_url && ! $has_b64 ) { |
| 1934 | $results[] = [ 'source' => '', 'success' => false, 'error' => 'Each item needs a url or base64.' ]; |
| 1935 | continue; |
| 1936 | } |
| 1937 | if ( $has_url && $has_b64 ) { |
| 1938 | $results[] = [ 'source' => '', 'success' => false, 'error' => 'Provide only one of url or base64 per item.' ]; |
| 1939 | continue; |
| 1940 | } |
| 1941 | |
| 1942 | $source_ref = $has_url ? esc_url_raw( trim( (string) $item['url'] ) ) : '(base64)'; |
| 1943 | $filename = isset($item['filename']) ? sanitize_file_name( (string) $item['filename'] ) : ''; |
| 1944 | |
| 1945 | // Resolve the file to a temp path from whichever source was supplied. |
| 1946 | if ( $has_url ) { |
| 1947 | $url = esc_url_raw( trim( (string) $item['url'] ) ); |
| 1948 | |
| 1949 | // SSRF guard on the host. |
| 1950 | $ssrf = atarim_media_import_check_url_safety($url); |
| 1951 | if ( $ssrf !== null ) { |
| 1952 | $results[] = [ 'source' => $url, 'success' => false, 'error' => $ssrf ]; |
| 1953 | continue; |
| 1954 | } |
| 1955 | |
| 1956 | // Fetch server-side, no redirects, with a size guard. |
| 1957 | $fetched = atarim_media_import_fetch($url); |
| 1958 | if ( ! empty($fetched['error']) ) { |
| 1959 | $results[] = [ 'source' => $url, 'success' => false, 'error' => $fetched['error'] ]; |
| 1960 | continue; |
| 1961 | } |
| 1962 | $tmp = $fetched['tmp']; |
| 1963 | |
| 1964 | // Filename: explicit, else basename of the URL path. |
| 1965 | if ( $filename === '' ) { |
| 1966 | $path = wp_parse_url($url, PHP_URL_PATH); |
| 1967 | $filename = $path ? sanitize_file_name( basename($path) ) : ''; |
| 1968 | } |
| 1969 | } else { |
| 1970 | // base64: a filename is required (we need an extension to validate type). |
| 1971 | if ( $filename === '' ) { |
| 1972 | $results[] = [ 'source' => $source_ref, 'success' => false, 'error' => 'filename is required for base64 items.' ]; |
| 1973 | continue; |
| 1974 | } |
| 1975 | |
| 1976 | $decoded = atarim_media_import_decode_base64( (string) $item['base64'] ); |
| 1977 | if ( ! empty($decoded['error']) ) { |
| 1978 | $results[] = [ 'source' => $source_ref, 'success' => false, 'error' => $decoded['error'] ]; |
| 1979 | continue; |
| 1980 | } |
| 1981 | $tmp = $decoded['tmp']; |
| 1982 | } |
| 1983 | |
| 1984 | if ( $filename === '' ) { |
| 1985 | $filename = 'attachment'; |
| 1986 | } |
| 1987 | |
| 1988 | // Validate the type against the site's allowed MIME types. |
| 1989 | $filetype = wp_check_filetype_and_ext($tmp, $filename); |
| 1990 | if ( empty($filetype['type']) ) { |
| 1991 | @unlink($tmp); |
| 1992 | $results[] = [ 'source' => $source_ref, 'success' => false, 'error' => 'File type is not allowed on this site.' ]; |
| 1993 | continue; |
| 1994 | } |
| 1995 | if ( ! empty($filetype['proper_filename']) ) { |
| 1996 | $filename = $filetype['proper_filename']; |
| 1997 | } |
| 1998 | |
| 1999 | $file_array = [ 'name' => $filename, 'tmp_name' => $tmp ]; |
| 2000 | |
| 2001 | // Sideload into the library, unattached (post_id 0). |
| 2002 | $attachment_id = media_handle_sideload($file_array, 0); |
| 2003 | |
| 2004 | if ( is_wp_error($attachment_id) ) { |
| 2005 | @unlink($tmp); // media_handle_sideload usually cleans up, but be safe. |
| 2006 | $results[] = [ 'source' => $source_ref, 'success' => false, 'error' => 'Import failed: ' . $attachment_id->get_error_message() ]; |
| 2007 | continue; |
| 2008 | } |
| 2009 | |
| 2010 | // Optional alt text / title. |
| 2011 | if ( ! empty($item['alt']) ) { |
| 2012 | update_post_meta($attachment_id, '_wp_attachment_image_alt', sanitize_text_field((string) $item['alt'])); |
| 2013 | } |
| 2014 | if ( ! empty($item['title']) ) { |
| 2015 | wp_update_post([ 'ID' => $attachment_id, 'post_title' => sanitize_text_field((string) $item['title']) ]); |
| 2016 | } |
| 2017 | |
| 2018 | $results[] = [ |
| 2019 | 'source' => $source_ref, |
| 2020 | 'success' => true, |
| 2021 | 'attachmentId' => (int) $attachment_id, |
| 2022 | 'mediaUrl' => wp_get_attachment_url($attachment_id), |
| 2023 | 'mimeType' => get_post_mime_type($attachment_id), |
| 2024 | 'filename' => $filename, |
| 2025 | ]; |
| 2026 | } |
| 2027 | |
| 2028 | return new WP_REST_Response([ 'status' => true, 'results' => $results ], 200); |
| 2029 | } |
| 2030 | |
| 2031 | /** |
| 2032 | * Fetch a URL to a temp file without following redirects, with a size guard. |
| 2033 | * Returns [ 'tmp' => path ] or [ 'error' => message ]. |
| 2034 | */ |
| 2035 | function atarim_media_import_fetch(string $url) { |
| 2036 | if ( ! function_exists('wp_tempnam') ) { |
| 2037 | require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 2038 | } |
| 2039 | |
| 2040 | $resp = wp_remote_get($url, [ 'timeout' => 300, 'redirection' => 0 ]); |
| 2041 | if ( is_wp_error($resp) ) { |
| 2042 | return [ 'error' => 'Download failed: ' . $resp->get_error_message() ]; |
| 2043 | } |
| 2044 | |
| 2045 | $code = (int) wp_remote_retrieve_response_code($resp); |
| 2046 | if ( $code < 200 || $code >= 300 ) { |
| 2047 | return [ 'error' => sprintf('Download rejected (HTTP %d).', $code) ]; |
| 2048 | } |
| 2049 | |
| 2050 | $body = wp_remote_retrieve_body($resp); |
| 2051 | if ( $body === '' ) { |
| 2052 | return [ 'error' => 'Downloaded file is empty.' ]; |
| 2053 | } |
| 2054 | |
| 2055 | $max = wp_max_upload_size(); |
| 2056 | if ( $max > 0 && strlen($body) > $max ) { |
| 2057 | return [ 'error' => sprintf('File exceeds the maximum upload size (%s).', size_format($max)) ]; |
| 2058 | } |
| 2059 | |
| 2060 | $tmp = wp_tempnam($url); |
| 2061 | if ( ! $tmp ) { |
| 2062 | return [ 'error' => 'Could not create a temporary file.' ]; |
| 2063 | } |
| 2064 | if ( false === file_put_contents($tmp, $body) ) { |
| 2065 | @unlink($tmp); |
| 2066 | return [ 'error' => 'Could not write the downloaded file.' ]; |
| 2067 | } |
| 2068 | |
| 2069 | return [ 'tmp' => $tmp ]; |
| 2070 | } |
| 2071 | |
| 2072 | /** |
| 2073 | * Decode a base64 payload (optionally a data: URI) to a temp file, with a size |
| 2074 | * guard. Returns [ 'tmp' => path ] or [ 'error' => message ]. |
| 2075 | */ |
| 2076 | function atarim_media_import_decode_base64( $b64 ) { |
| 2077 | if ( ! function_exists('wp_tempnam') ) { |
| 2078 | require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 2079 | } |
| 2080 | |
| 2081 | $b64 = (string) $b64; |
| 2082 | // Strip a data: URI prefix if present (e.g. "data:image/png;base64,...."). |
| 2083 | if ( stripos( $b64, 'base64,' ) !== false ) { |
| 2084 | $b64 = substr( $b64, stripos( $b64, 'base64,' ) + 7 ); |
| 2085 | } |
| 2086 | $b64 = trim( $b64 ); |
| 2087 | |
| 2088 | $decoded = base64_decode( $b64, true ); |
| 2089 | if ( $decoded === false ) { |
| 2090 | return [ 'error' => 'Invalid base64 data.' ]; |
| 2091 | } |
| 2092 | if ( $decoded === '' ) { |
| 2093 | return [ 'error' => 'Decoded file is empty.' ]; |
| 2094 | } |
| 2095 | |
| 2096 | $max = wp_max_upload_size(); |
| 2097 | if ( $max > 0 && strlen( $decoded ) > $max ) { |
| 2098 | return [ 'error' => sprintf( 'File exceeds the maximum upload size (%s).', size_format( $max ) ) ]; |
| 2099 | } |
| 2100 | |
| 2101 | $tmp = wp_tempnam(); |
| 2102 | if ( ! $tmp ) { |
| 2103 | return [ 'error' => 'Could not create a temporary file.' ]; |
| 2104 | } |
| 2105 | if ( false === file_put_contents( $tmp, $decoded ) ) { |
| 2106 | @unlink( $tmp ); |
| 2107 | return [ 'error' => 'Could not write the decoded file.' ]; |
| 2108 | } |
| 2109 | |
| 2110 | return [ 'tmp' => $tmp ]; |
| 2111 | } |
| 2112 | |
| 2113 | /** |
| 2114 | * SSRF guard for outbound fetches. Mirrors the media cluster's check: blocks |
| 2115 | * non-http(s) schemes, localhost, and hosts resolving to private/loopback/ |
| 2116 | * link-local ranges (incl. the 169.254.169.254 metadata IP). Returns null when |
| 2117 | * safe, or an error string. |
| 2118 | */ |
| 2119 | function atarim_media_import_check_url_safety($url) { |
| 2120 | $parsed = wp_parse_url($url); |
| 2121 | if ( ! is_array($parsed) || empty($parsed['scheme']) || empty($parsed['host']) ) { |
| 2122 | return 'Invalid URL — could not parse scheme and host.'; |
| 2123 | } |
| 2124 | |
| 2125 | $scheme = strtolower($parsed['scheme']); |
| 2126 | if ( $scheme !== 'http' && $scheme !== 'https' ) { |
| 2127 | return sprintf('URL scheme "%s" is not allowed — only http and https are supported.', $scheme); |
| 2128 | } |
| 2129 | |
| 2130 | $host = strtolower($parsed['host']); |
| 2131 | if ( in_array($host, [ 'localhost', 'localhost.localdomain' ], true) ) { |
| 2132 | return 'Hostname "localhost" is not allowed.'; |
| 2133 | } |
| 2134 | |
| 2135 | $ips = @gethostbynamel($host); |
| 2136 | if ( ! is_array($ips) ) { |
| 2137 | if ( filter_var($host, FILTER_VALIDATE_IP) ) { |
| 2138 | $ips = [ $host ]; |
| 2139 | } else { |
| 2140 | return sprintf('Could not resolve host "%s".', $host); |
| 2141 | } |
| 2142 | } |
| 2143 | |
| 2144 | foreach ( $ips as $ip ) { |
| 2145 | if ( ! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) ) { |
| 2146 | return sprintf('URL host resolves to a blocked address (%s — private, loopback, or link-local range).', $ip); |
| 2147 | } |
| 2148 | if ( $ip === '169.254.169.254' ) { |
| 2149 | return 'URL host resolves to a cloud metadata endpoint (169.254.169.254) — blocked.'; |
| 2150 | } |
| 2151 | } |
| 2152 | |
| 2153 | return null; |
| 2154 | } |