PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / 5.1.3
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback v5.1.3
5.1.3 5.1.2 5.1.1 5.1 5.0 trunk 3.10 3.11 3.12 3.13 3.14 3.15 3.16 3.17 3.18 3.19 3.2.0 3.2.1 3.22 3.22.1 3.22.2 3.22.3 3.22.4 3.22.5 3.22.6 3.3.0 3.3.1 3.3.2 3.3.2.1 3.3.2.2 3.3.3 3.30 3.31 3.32 3.4 3.4.1 3.4.3 3.4.4 3.5 3.5.1 3.6 3.6.1 3.7 3.8 3.9 3.9.1 3.9.2 3.9.3 3.9.4 3.9.6 3.9.6.1 4.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.2 4.1.3 4.2 4.2.1 4.2.2 4.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.4
atarim-visual-collaboration / includes / do-it.php
atarim-visual-collaboration / includes Last commit date
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 }