PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / 5.1.1
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback v5.1.1
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 2 weeks ago class-define-constant.php 2 weeks ago class-functions.php 2 weeks ago do-it.php 2 weeks ago inject-script.php 2 weeks ago
do-it.php
1196 lines
1 <?php
2
3 if ( ! defined('ABSPATH') ) exit;
4
5 add_action('wp_enqueue_scripts', function () {
6
7 if ( ! is_singular() ) return;
8 if ( ! is_user_logged_in() ) return;
9 if ( ! current_user_can('edit_posts') ) return;
10
11 $post_id = get_queried_object_id();
12 if ( ! $post_id ) return;
13
14 $page_builder = atarim_detect_page_builder($post_id);
15 $wrapper_hint = atarim_detect_wrapper_selector_by_theme(); // '' if unknown
16
17 $handle = 'atarim-do-it';
18 wp_register_script($handle, '', [], '0.3.1', false);
19 wp_enqueue_script($handle);
20
21 $atarim_inline_data = [
22 'postId' => (int) $post_id,
23 'pageBuilder' => $page_builder,
24 'wrapperHint' => $wrapper_hint,
25 'apiGet' => esc_url_raw(rest_url('atarim/v1/content/get')),
26 'apiSave' => esc_url_raw(rest_url('atarim/v1/content/save')),
27 'apiMediaImport' => esc_url_raw(rest_url('atarim/v1/media/import')),
28 'nonce' => wp_create_nonce('wp_rest'),
29 ];
30
31 $atarim_inline_data = apply_filters('atarim_inline_data', $atarim_inline_data, $post_id);
32 wp_localize_script($handle, 'ATARIM_INLINE', $atarim_inline_data);
33
34 /* This is how to use filter
35 add_filter('atarim_inline_data', function ($data, $post_id) {
36 if (empty($data['wrapperHint'])) {
37 $data['wrapperHint'] = '.site-main .entry-content';
38 }
39 return $data;
40 }, 10, 2);*/
41 });
42
43 /* ===========================
44 * Block identity injection (for editors only)
45 * Marks every rendered block with data-atarim-block-name
46 * and data-atarim-anchor-index so the frontend can identify
47 * blocks reliably without DOM heuristics.
48 * =========================== */
49 add_action('template_redirect', function () {
50
51 if ( ! is_singular() ) return;
52 if ( ! is_user_logged_in() ) return;
53 if ( ! current_user_can('edit_posts') ) return;
54
55 $post_id = get_queried_object_id();
56 if ( ! $post_id ) return;
57
58 if ( atarim_detect_page_builder($post_id) !== 'block' ) return;
59
60 $GLOBALS['atarim_block_counters'] = [];
61
62 add_filter('render_block', 'atarim_inject_block_identity', 10, 2);
63 });
64
65 function atarim_inject_block_identity(string $block_content, array $block): string {
66
67 if (empty($block['blockName'])) return $block_content;
68 if (trim($block_content) === '') return $block_content;
69
70 $block_name = $block['blockName'];
71
72 if (!isset($GLOBALS['atarim_block_counters'][$block_name])) {
73 $GLOBALS['atarim_block_counters'][$block_name] = 0;
74 }
75 $anchor_index = $GLOBALS['atarim_block_counters'][$block_name];
76 $GLOBALS['atarim_block_counters'][$block_name]++;
77
78 if (class_exists('WP_HTML_Tag_Processor')) {
79 $tags = new WP_HTML_Tag_Processor($block_content);
80 if ($tags->next_tag()) {
81 $tags->set_attribute('data-atarim-block-name', $block_name);
82 $tags->set_attribute('data-atarim-anchor-index', (string) $anchor_index);
83 return $tags->get_updated_html();
84 }
85 return $block_content;
86 }
87
88 // Fallback for older WP versions
89 $attrs = sprintf(
90 ' data-atarim-block-name="%s" data-atarim-anchor-index="%d"',
91 esc_attr($block_name),
92 $anchor_index
93 );
94
95 return preg_replace(
96 '/^(\s*<[a-zA-Z][a-zA-Z0-9]*)\b/',
97 '$1' . $attrs,
98 $block_content,
99 1
100 );
101 }
102
103 /* ===========================
104 * Post-title identity marker (for editors only)
105 * Adds a bare data-atarim-post-title attribute to the element that renders the
106 * dynamic post title — the core/post-title block (Gutenberg) and the
107 * theme-post-title widget (Elementor) — so the frontend can recognise the
108 * title with certainty and route edits to the post_title save path. No value is
109 * needed: the frontend already has the post ID via ATARIM_INLINE.postId
110 * (get_queried_object_id()).
111 * =========================== */
112 add_action('template_redirect', function () {
113
114 if ( ! is_singular() ) return;
115 if ( ! is_user_logged_in() ) return;
116 if ( ! current_user_can('edit_posts') ) return;
117 if ( ! get_queried_object_id() ) return;
118
119 // Gutenberg: core/post-title block.
120 add_filter('render_block_core/post-title', 'atarim_mark_post_title_block', 10, 3);
121
122 // Elementor: theme-post-title widget.
123 add_filter('elementor/widget/render_content', 'atarim_mark_post_title_elementor', 10, 2);
124 });
125
126 /**
127 * Add a bare data-atarim-post-title attribute to the root tag of the rendered
128 * core/post-title block. Skips title blocks that render a DIFFERENT post inside
129 * a query loop (only the queried post's own title is marked).
130 */
131 function atarim_mark_post_title_block($block_content, $block = [], $instance = null) {
132 if (trim((string) $block_content) === '') return $block_content;
133
134 if ($instance instanceof WP_Block && isset($instance->context['postId'])) {
135 if ((int) $instance->context['postId'] !== (int) get_queried_object_id()) {
136 return $block_content;
137 }
138 }
139
140 return atarim_add_post_title_attr($block_content);
141 }
142
143 /**
144 * Add a bare data-atarim-post-title attribute to the title tag inside Elementor's
145 * theme-post-title widget. Skips loop items that render a different post.
146 */
147 function atarim_mark_post_title_elementor($content, $widget) {
148 if (! is_object($widget) || ! method_exists($widget, 'get_name')) return $content;
149 if ($widget->get_name() !== 'theme-post-title') return $content;
150 if (trim((string) $content) === '') return $content;
151
152 // In an Elementor loop the global post is swapped per item; only mark the
153 // queried post's own title.
154 $current = get_the_ID();
155 if ($current && (int) $current !== (int) get_queried_object_id()) {
156 return $content;
157 }
158
159 return atarim_add_post_title_attr($content);
160 }
161
162 /**
163 * Set a bare data-atarim-post-title attribute on the first tag of $html.
164 */
165 function atarim_add_post_title_attr(string $html): string {
166 if (class_exists('WP_HTML_Tag_Processor')) {
167 $tags = new WP_HTML_Tag_Processor($html);
168 if ($tags->next_tag()) {
169 $tags->set_attribute('data-atarim-post-title', true); // boolean true => bare attribute
170 return $tags->get_updated_html();
171 }
172 return $html;
173 }
174
175 // Fallback for older WP: inject a bare attribute into the first tag.
176 return preg_replace('/^(\s*<[a-zA-Z][a-zA-Z0-9]*)\b/', '$1 data-atarim-post-title', $html, 1);
177 }
178
179 add_action('rest_api_init', function () {
180
181 $permission_callback = function( WP_REST_Request $request ) {
182 if ( empty( get_option( 'avc_enable_doit', false ) ) ) {
183 return new WP_Error(
184 'avc_doit_disabled',
185 __( 'Do It via Atarim AI is disabled for this site. Enable it from the Atarim plugin settings to allow execution.', 'atarim-visual-collaboration' ),
186 [ 'status' => 403 ]
187 );
188 }
189
190 $post_id = absint($request->get_param('postId'));
191 if ($post_id) return current_user_can('edit_post', $post_id);
192 return current_user_can('edit_posts');
193 };
194
195 register_rest_route('atarim/v1', '/content/get', [
196 'methods' => 'POST',
197 'callback' => 'atarim_inline_get_handler',
198 'permission_callback' => $permission_callback,
199 ]);
200
201 register_rest_route('atarim/v1', '/content/save', [
202 'methods' => 'POST',
203 'callback' => 'atarim_inline_save_handler',
204 'permission_callback' => $permission_callback,
205 ]);
206
207 // Media import: push external file URLs (e.g. task/comment attachments) into
208 // the media library, unattached. Own permission — needs upload_files, not the
209 // post-scoped edit_post the content routes use.
210 $media_permission_callback = function ( WP_REST_Request $request ) {
211 if ( empty( get_option( 'avc_enable_doit', false ) ) ) {
212 return new WP_Error(
213 'avc_doit_disabled',
214 __( 'Do It via Atarim AI is disabled for this site. Enable it from the Atarim plugin settings to allow execution.', 'atarim-visual-collaboration' ),
215 [ 'status' => 403 ]
216 );
217 }
218 return current_user_can( 'upload_files' );
219 };
220
221 register_rest_route('atarim/v1', '/media/import', [
222 'methods' => 'POST',
223 'callback' => 'atarim_inline_media_import_handler',
224 'permission_callback' => $media_permission_callback,
225 ]);
226
227 // ---------------------------------------------------------------------
228 // Core connection probe — NOT a DoIt route. Public and intentionally
229 // ungated: the Atarim app calls it cross-origin and unauthenticated,
230 // before any connection/token exists, to decide "Connect" vs "Install".
231 // It deliberately does NOT use $permission_callback (the avc_enable_doit
232 // gate) above. Lives here only because this file already registers the
233 // atarim/v1 namespace; move to its own home if more core routes appear.
234 // ---------------------------------------------------------------------
235 register_rest_route('atarim/v1', '/status', [
236 'methods' => 'GET',
237 'permission_callback' => '__return_true',
238 'callback' => function () {
239 $connected = get_option('avc_collab_active', 'no') === 'yes';
240 return [
241 'installed' => true,
242 'connected' => $connected,
243 'version' => defined('AVCF_VERSION') ? AVCF_VERSION : null,
244 'settings_url' => admin_url('options-general.php?page=atarim-visual-collaboration'),
245 'site_url' => site_url(),
246 ];
247 },
248 ]);
249 });
250
251 /* ===========================
252 * Builder + wrapper detection
253 * =========================== */
254
255 function atarim_detect_page_builder(int $post_id): string {
256
257 $elementor_data = get_post_meta($post_id, '_elementor_data', true);
258 if (is_string($elementor_data) && trim($elementor_data) !== '') return 'elementor';
259 if (is_array($elementor_data) && !empty($elementor_data)) return 'elementor';
260
261 $post = get_post($post_id);
262 if ($post && isset($post->post_content) && has_blocks($post->post_content)) return 'block';
263
264 if ($post && trim(wp_strip_all_tags($post->post_content)) !== '' && preg_match('/<[a-z][a-z0-9]*\b[^>]*>/i', $post->post_content)) {
265 return 'classic';
266 }
267
268 return '';
269 }
270
271 function atarim_detect_wrapper_selector_by_theme(): string {
272
273 $theme = wp_get_theme();
274 $template = strtolower((string) $theme->get_template());
275 $stylesheet = strtolower((string) $theme->get_stylesheet());
276 $slug = $template ?: $stylesheet;
277
278 $map = [
279 'hello-elementor' => '.page-content',
280 'oceanwp' => '.entry.clr',
281 // Expand later...
282 ];
283
284 return $map[$slug] ?? '';
285 }
286
287 /* ===========================
288 * REST: GET
289 * =========================== */
290
291 function atarim_inline_get_handler(WP_REST_Request $request) {
292
293 $post_id = absint($request->get_param('postId'));
294 $page_builder = sanitize_text_field((string) $request->get_param('pageBuilder'));
295
296 if (!$post_id) {
297 return new WP_REST_Response(['status' => false, 'message' => 'Missing postId.'], 400);
298 }
299
300 // Post-title target: independent of the page builder (the title is the
301 // queried post's post_title, wherever/however the theme renders it).
302 if ($request->get_param('target') === 'post_title') {
303 $post = get_post($post_id);
304 if (!$post) return new WP_REST_Response(['status'=>false,'message'=>'Post not found.'], 404);
305
306 return new WP_REST_Response([
307 'status' => true,
308 'target' => 'post_title',
309 'postId' => $post_id,
310 'title' => $post->post_title,
311 'slug' => $post->post_name,
312 ], 200);
313 }
314
315 if (!in_array($page_builder, ['elementor', 'block', 'classic'], true)) {
316 return new WP_REST_Response([
317 'status' => false,
318 'message' => 'This page builder is not supported yet.',
319 ], 400);
320 }
321
322 $post = get_post($post_id);
323 if (!$post) return new WP_REST_Response(['status'=>false,'message'=>'Post not found.'], 404);
324
325 if ($page_builder === 'elementor') {
326 $widget_id = sanitize_text_field((string) $request->get_param('widgetId'));
327 if (!$widget_id) return new WP_REST_Response(['status'=>false,'message'=>'Missing widgetId.'], 400);
328
329 $elementor_data = atarim_elementor_get_document_data_array($post_id);
330 if (!is_array($elementor_data)) return new WP_REST_Response(['status'=>false,'message'=>'No valid _elementor_data found.'], 404);
331
332 $widget = atarim_elementor_find_element_by_id($elementor_data, $widget_id);
333 if (!is_array($widget)) return new WP_REST_Response(['status'=>false,'message'=>'Widget not found.'], 404);
334
335 return new WP_REST_Response([
336 'status' => true,
337 'pageBuilder' => 'elementor',
338 'postId' => $post_id,
339 'widgetId' => $widget_id,
340 'content' => $widget,
341 ], 200);
342 }
343
344 if ($page_builder === 'block') {
345
346 $block_name = sanitize_text_field((string) $request->get_param('blockName'));
347 $anchor_index = (int) $request->get_param('anchorIndex');
348 $snippet = (string) $request->get_param('snippet');
349
350 if (trim($block_name) === '') {
351 return new WP_REST_Response(['status'=>false,'message'=>'Missing blockName.'], 400);
352 }
353 if ($anchor_index < 0) {
354 return new WP_REST_Response(['status'=>false,'message'=>'Missing/invalid anchorIndex.'], 400);
355 }
356
357 $match = atarim_find_gutenberg_block_by_anchor_index(
358 $post->post_content,
359 $block_name,
360 $anchor_index,
361 $snippet
362 );
363
364 if (!$match) {
365 return new WP_REST_Response([
366 'status'=>false,
367 'message'=>'Could not find matching Gutenberg block.',
368 ], 404);
369 }
370
371 return new WP_REST_Response([
372 'status' => true,
373 'pageBuilder' => 'block',
374 'postId' => $post_id,
375 'blockName' => $block_name,
376 'anchorIndex' => $anchor_index,
377 'snippet' => $snippet,
378 'blockPath' => $match['blockPath'], // nested like "3.0.1"
379 'content' => $match['serializedBlock'], // raw serialized block string
380 ], 200);
381 }
382
383 // Classic
384 $path_string = (string) $request->get_param('path');
385 $tag = strtolower((string) $request->get_param('tag'));
386 $snippet = (string) $request->get_param('snippet');
387
388 if (trim($path_string) === '' || trim($tag) === '' || trim($snippet) === '') {
389 return new WP_REST_Response(['status'=>false,'message'=>'Missing path, tag, or snippet.'], 400);
390 }
391
392 $steps = atarim_parse_compact_path($path_string);
393 $found = atarim_classic_find_node_outer_html($post->post_content, $steps, $tag, $snippet);
394
395 if (!$found) {
396 return new WP_REST_Response(['status'=>false,'message'=>'Could not find matching HTML element in classic content.'], 404);
397 }
398
399 return new WP_REST_Response([
400 'status' => true,
401 'pageBuilder' => 'classic',
402 'postId' => $post_id,
403 'path' => $path_string,
404 'tag' => $tag,
405 'snippet' => $snippet,
406 'content' => $found,
407 ], 200);
408 }
409
410 /* ===========================
411 * REST: SAVE
412 * =========================== */
413
414 /**
415 * Build a write-receipt for an inline save, measured from the RE-READ stored
416 * state (never echoed back), so a caller can confirm a write actually took
417 * effect without trusting a bare success and without re-fetching the whole
418 * document. Addresses the "success but nothing changed" class: verified compares
419 * what we intended to store against what is actually stored now, byte-for-byte —
420 * so a silent transform/kses/no-op shows up as verified:false.
421 *
422 * @param string $intended The exact string we tried to store.
423 * @param string $stored The string actually stored now (re-read).
424 * @param string|null $before The stored string before the write (for changed).
425 * @param int|null $revision_id Latest revision id, if the write created one.
426 * @return array
427 */
428 function atarim_inline_save_receipt( $intended, $stored, $before = null, $revision_id = null ) {
429 $intended = is_string( $intended ) ? $intended : (string) wp_json_encode( $intended );
430 $stored = is_string( $stored ) ? $stored : (string) wp_json_encode( $stored );
431
432 $receipt = [
433 'verified' => ( sha1( $intended ) === sha1( $stored ) ),
434 'storedBytes' => strlen( $stored ),
435 'storedSha1' => sha1( $stored ),
436 ];
437
438 if ( $before !== null ) {
439 $before = is_string( $before ) ? $before : (string) wp_json_encode( $before );
440 $receipt['changed'] = ( sha1( $before ) !== sha1( $stored ) );
441 }
442 if ( $revision_id !== null ) {
443 $receipt['revisionId'] = (int) $revision_id;
444 }
445
446 return $receipt;
447 }
448
449 function atarim_inline_save_handler(WP_REST_Request $request) {
450
451 $post_id = absint($request->get_param('postId'));
452 $page_builder = sanitize_text_field((string) $request->get_param('pageBuilder'));
453
454 if (!$post_id) {
455 return new WP_REST_Response(['status' => false, 'message' => 'Missing postId.'], 400);
456 }
457
458 // Post-title target: update post_title and (optionally) the slug, independent
459 // of the page builder.
460 if ($request->get_param('target') === 'post_title') {
461 $post = get_post($post_id);
462 if (!$post) return new WP_REST_Response(['status'=>false,'message'=>'Post not found.'], 404);
463
464 $new_title = trim((string) $request->get_param('title'));
465 if ($new_title === '') {
466 return new WP_REST_Response(['status'=>false,'message'=>'Missing title to save.'], 400);
467 }
468
469 $old_title = $post->post_title;
470 $old_slug = $post->post_name;
471
472 $update = [
473 'ID' => $post_id,
474 'post_title' => $new_title, // wp_update_post sanitises
475 ];
476
477 // Slug is optional: only touched when updateSlug is truthy. When on with
478 // no explicit slug, regenerate a unique slug from the new title.
479 $update_slug = filter_var($request->get_param('updateSlug'), FILTER_VALIDATE_BOOLEAN);
480 $new_slug = $old_slug;
481 if ($update_slug) {
482 $explicit = sanitize_title((string) $request->get_param('slug'));
483 $desired = $explicit !== '' ? $explicit : sanitize_title($new_title);
484 if ($desired === '') { $desired = $old_slug; }
485 $new_slug = wp_unique_post_slug($desired, $post_id, $post->post_status, $post->post_type, $post->post_parent);
486 $update['post_name'] = $new_slug;
487 }
488
489 $result = wp_update_post(wp_slash($update), true);
490 if (is_wp_error($result)) {
491 return new WP_REST_Response(['status'=>false,'message'=>'Failed to update title: ' . $result->get_error_message()], 500);
492 }
493
494 $saved = get_post($post_id);
495 $final_slug = $saved ? $saved->post_name : $new_slug;
496
497 return new WP_REST_Response([
498 'status' => true,
499 'target' => 'post_title',
500 'postId' => $post_id,
501 'oldTitle' => $old_title,
502 'title' => $saved ? $saved->post_title : $new_title,
503 'slugUpdated' => ($final_slug !== $old_slug),
504 'oldSlug' => $old_slug,
505 'slug' => $final_slug,
506 ], 200);
507 }
508
509 if (!in_array($page_builder, ['elementor', 'block', 'classic'], true)) {
510 return new WP_REST_Response([
511 'status' => false,
512 'message' => 'This page builder is not supported yet.',
513 ], 400);
514 }
515
516 $post = get_post($post_id);
517 if (!$post) return new WP_REST_Response(['status'=>false,'message'=>'Post not found.'], 404);
518
519 if ($page_builder === 'elementor') {
520 $widget_id = sanitize_text_field((string) $request->get_param('widgetId'));
521 $widget = $request->get_param('content');
522
523 if (!$widget_id) return new WP_REST_Response(['status'=>false,'message'=>'Missing widgetId.'], 400);
524 if (!is_array($widget)) return new WP_REST_Response(['status'=>false,'message'=>'Elementor content must be a JSON object.'], 400);
525
526 if (empty($widget['id']) || (string)$widget['id'] !== (string)$widget_id) {
527 return new WP_REST_Response(['status'=>false,'message'=>'content.id must match widgetId.'], 400);
528 }
529
530 $elementor_data = atarim_elementor_get_document_data_array($post_id);
531 if (!is_array($elementor_data)) return new WP_REST_Response(['status'=>false,'message'=>'No valid _elementor_data found.'], 404);
532
533 $replaced = false;
534 $updated_data = atarim_elementor_replace_element_by_id($elementor_data, $widget_id, $widget, $replaced);
535 if (!$replaced) return new WP_REST_Response(['status'=>false,'message'=>'Widget not found; nothing saved.'], 404);
536
537 $intended_json = wp_json_encode($updated_data);
538 update_post_meta($post_id, '_elementor_data', wp_slash($intended_json));
539
540 delete_post_meta($post_id, '_elementor_element_cache');
541 delete_post_meta($post_id, '_elementor_page_assets');
542
543 if ( class_exists('\Elementor\Core\Files\CSS\Post') ) {
544 try { ( new \Elementor\Core\Files\CSS\Post($post_id) )->delete(); } catch (Throwable $e) {}
545 }
546
547 clean_post_cache($post_id);
548
549 $stored_json = get_post_meta($post_id, '_elementor_data', true);
550 if ( ! is_string($stored_json) ) { $stored_json = (string) wp_json_encode($stored_json); }
551 $receipt = atarim_inline_save_receipt( $intended_json, $stored_json, wp_json_encode($elementor_data) );
552
553 return new WP_REST_Response(array_merge(['status'=>true, 'target'=>'elementor', 'widgetId'=>$widget_id], $receipt), 200);
554 }
555
556 $content = (string) $request->get_param('content');
557 if (trim($content) === '') {
558 return new WP_REST_Response(['status'=>false,'message'=>'Missing content to save.'], 400);
559 }
560
561 if ($page_builder === 'block') {
562 $block_path = (string) $request->get_param('blockPath');
563 $expected_block_name = sanitize_text_field((string) $request->get_param('blockName'));
564
565 if (trim($block_path) === '') {
566 return new WP_REST_Response(['status'=>false,'message'=>'Missing blockPath.'], 400);
567 }
568 if (trim($expected_block_name) === '') {
569 return new WP_REST_Response(['status'=>false,'message'=>'Missing blockName.'], 400);
570 }
571
572 // Validate: new content parses as a single block of the expected type
573 $parsed_new = parse_blocks($content);
574 if (!is_array($parsed_new) || empty($parsed_new) || !is_array($parsed_new[0])) {
575 return new WP_REST_Response([
576 'status'=>false,
577 'message'=>'Content is not a valid block.',
578 ], 400);
579 }
580 if (($parsed_new[0]['blockName'] ?? '') !== $expected_block_name) {
581 return new WP_REST_Response([
582 'status'=>false,
583 'message'=>'Content block type does not match expected blockName.',
584 ], 400);
585 }
586
587 // Validate: existing block at blockPath is the expected type (stale-edit guard)
588 $existing_blocks = parse_blocks($post->post_content);
589 $path_parts = array_values(array_filter(
590 explode('.', trim($block_path)),
591 static function($v) { return $v !== ''; }
592 ));
593 $existing_block = atarim_get_block_ref_by_path($existing_blocks, $path_parts);
594 if (!is_array($existing_block) || ($existing_block['blockName'] ?? '') !== $expected_block_name) {
595 return new WP_REST_Response([
596 'status'=>false,
597 'message'=>'Block at path no longer matches expected type. The post may have been edited elsewhere; please refresh.',
598 ], 409);
599 }
600
601 $updated = atarim_replace_gutenberg_block_by_nested_path($post->post_content, $block_path, $content);
602 if ($updated === null) {
603 return new WP_REST_Response([
604 'status'=>false,
605 'message'=>'Could not replace Gutenberg block (path not found or invalid replacement).',
606 ], 404);
607 }
608
609 wp_update_post([
610 'ID' => $post_id,
611 'post_content' => $updated,
612 ]);
613
614 clean_post_cache($post_id);
615
616 $stored_content = (string) get_post_field('post_content', $post_id);
617 $revs = wp_get_post_revisions($post_id, ['numberposts'=>1, 'fields'=>'ids']);
618 $receipt = atarim_inline_save_receipt( $updated, $stored_content, $post->post_content, $revs ? (int) reset($revs) : null );
619
620 return new WP_REST_Response(array_merge(['status'=>true, 'target'=>'block'], $receipt), 200);
621 }
622
623 // Classic save
624 $path_string = (string) $request->get_param('path');
625 $tag = strtolower((string) $request->get_param('tag'));
626 $snippet = (string) $request->get_param('snippet');
627
628 if (trim($path_string) === '' || trim($tag) === '' || trim($snippet) === '') {
629 return new WP_REST_Response(['status'=>false,'message'=>'Missing path/tag/snippet for classic save.'], 400);
630 }
631
632 $steps = atarim_parse_compact_path($path_string);
633
634 $new_post_content = atarim_classic_replace_node_outer_html($post->post_content, $steps, $tag, $snippet, $content);
635 if ($new_post_content === null) {
636 return new WP_REST_Response(['status'=>false,'message'=>'Could not find matching element to replace in classic content.'], 404);
637 }
638
639 wp_update_post([
640 'ID' => $post_id,
641 'post_content' => $new_post_content,
642 ]);
643
644 clean_post_cache($post_id);
645
646 $stored_content = (string) get_post_field('post_content', $post_id);
647 $revs = wp_get_post_revisions($post_id, ['numberposts'=>1, 'fields'=>'ids']);
648 $receipt = atarim_inline_save_receipt( $new_post_content, $stored_content, $post->post_content, $revs ? (int) reset($revs) : null );
649
650 return new WP_REST_Response(array_merge(['status'=>true, 'target'=>'classic'], $receipt), 200);
651 }
652
653 /* ===========================
654 * Shared helpers: compact path (classic only)
655 * =========================== */
656
657 function atarim_parse_compact_path(string $path): array {
658 $path = trim($path);
659 if ($path === '') return [];
660
661 $parts = array_map('trim', explode('>', $path));
662 $steps = [];
663
664 foreach ($parts as $part) {
665 $part = trim($part);
666 if ($part === '') continue;
667
668 if (preg_match('/^([a-z0-9]+)(?:\((\d+)\))?$/i', $part, $m)) {
669 $steps[] = [
670 'tag' => strtoupper($m[1]),
671 'index' => isset($m[2]) ? (int) $m[2] : 0,
672 ];
673 }
674 }
675
676 return $steps;
677 }
678
679 /* ===========================
680 * Elementor helpers
681 * =========================== */
682
683 function atarim_elementor_get_document_data_array(int $post_id): ?array {
684 $raw = get_post_meta($post_id, '_elementor_data', true);
685 if (empty($raw)) return null;
686
687 if (is_string($raw)) {
688 $decoded = json_decode($raw, true);
689 return is_array($decoded) ? $decoded : null;
690 }
691
692 return is_array($raw) ? $raw : null;
693 }
694
695 function atarim_elementor_find_element_by_id(array $nodes, string $target_id): ?array {
696 foreach ($nodes as $node) {
697 if (!is_array($node)) continue;
698
699 if (isset($node['id']) && (string)$node['id'] === (string)$target_id) {
700 return $node;
701 }
702
703 if (isset($node['elements']) && is_array($node['elements'])) {
704 $found = atarim_elementor_find_element_by_id($node['elements'], $target_id);
705 if ($found !== null) return $found;
706 }
707 }
708 return null;
709 }
710
711 function atarim_elementor_replace_element_by_id(array $nodes, string $target_id, array $replacement_node, bool &$replaced): array {
712 foreach ($nodes as $index => $node) {
713 if (!is_array($node)) continue;
714
715 if (isset($node['id']) && (string)$node['id'] === (string)$target_id) {
716 $nodes[$index] = $replacement_node;
717 $replaced = true;
718 return $nodes;
719 }
720
721 if (isset($node['elements']) && is_array($node['elements'])) {
722 $nodes[$index]['elements'] = atarim_elementor_replace_element_by_id(
723 $node['elements'],
724 $target_id,
725 $replacement_node,
726 $replaced
727 );
728 if ($replaced) return $nodes;
729 }
730 }
731 return $nodes;
732 }
733
734 /* ===========================
735 * Gutenberg helpers (blockName + anchorIndex + nested path replace)
736 * =========================== */
737
738 function atarim_normalize_text(string $text): string {
739 $text = wp_strip_all_tags($text);
740 $text = preg_replace('/\s+/u', ' ', $text);
741 return strtolower(trim($text));
742 }
743
744 function atarim_collect_matching_blocks(array $blocks, string $block_name, array &$out, string $parent_path = ''): void {
745 foreach ($blocks as $i => $block) {
746 if (!is_array($block)) continue;
747
748 $current_block_name = (string)($block['blockName'] ?? '');
749 $path = ($parent_path === '') ? (string)$i : ($parent_path . '.' . $i);
750
751 if ($current_block_name === $block_name) {
752 $out[] = ['path' => $path, 'block' => $block];
753 }
754
755 if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
756 atarim_collect_matching_blocks($block['innerBlocks'], $block_name, $out, $path);
757 }
758 }
759 }
760
761 function atarim_find_gutenberg_block_by_anchor_index(string $post_content, string $block_name, int $anchor_index, string $snippet): ?array {
762 $blocks = parse_blocks($post_content);
763 if (!is_array($blocks)) return null;
764
765 $matches = [];
766 atarim_collect_matching_blocks($blocks, $block_name, $matches);
767
768 if (!isset($matches[$anchor_index])) return null;
769
770 $picked = $matches[$anchor_index]['block'];
771 $picked_path = $matches[$anchor_index]['path'];
772
773 $snippet_norm = atarim_normalize_text($snippet);
774
775 if ($snippet_norm !== '') {
776 $rendered = '';
777 try { $rendered = render_block($picked); } catch (Throwable $e) { $rendered = serialize_block($picked); }
778
779 if (!str_contains(atarim_normalize_text($rendered), $snippet_norm)) {
780 return null;
781 }
782 }
783
784 return [
785 'blockPath' => $picked_path,
786 'serializedBlock' => serialize_block($picked),
787 ];
788 }
789
790 function atarim_get_block_ref_by_path(array &$blocks, array $path_parts) {
791 $ref = &$blocks;
792 foreach ($path_parts as $part_index => $part) {
793 $idx = (int)$part;
794 if (!isset($ref[$idx]) || !is_array($ref[$idx])) return null;
795
796 if ($part_index === count($path_parts) - 1) {
797 return $ref[$idx];
798 }
799
800 if (!isset($ref[$idx]['innerBlocks']) || !is_array($ref[$idx]['innerBlocks'])) return null;
801 $ref = &$ref[$idx]['innerBlocks'];
802 }
803 return null;
804 }
805
806 function atarim_replace_gutenberg_block_by_nested_path(string $post_content, string $block_path, string $new_serialized_block): ?string {
807
808 $blocks = parse_blocks($post_content);
809 if (!is_array($blocks)) return null;
810
811 $replacement_blocks = parse_blocks($new_serialized_block);
812 if (!is_array($replacement_blocks) || empty($replacement_blocks) || !is_array($replacement_blocks[0])) {
813 return null;
814 }
815 $replacement_block = $replacement_blocks[0];
816
817 $parts = array_filter(explode('.', trim($block_path)), static function($v) { return $v !== ''; });
818 if (empty($parts)) return null;
819
820 $ref = &$blocks;
821
822 for ($i = 0; $i < count($parts) - 1; $i++) {
823 $idx = (int)$parts[$i];
824 if (!isset($ref[$idx]) || !is_array($ref[$idx])) return null;
825
826 if (!isset($ref[$idx]['innerBlocks']) || !is_array($ref[$idx]['innerBlocks'])) {
827 return null;
828 }
829
830 $ref = &$ref[$idx]['innerBlocks'];
831 }
832
833 $target_index = (int)$parts[count($parts) - 1];
834 if (!isset($ref[$target_index]) || !is_array($ref[$target_index])) return null;
835
836 $ref[$target_index] = $replacement_block;
837
838 return serialize_blocks($blocks);
839 }
840
841 /* ===========================
842 * Classic helpers (DOM path)
843 * =========================== */
844
845 function atarim_dom_load_fragment(string $html, string $wrap_id): array {
846 $dom = new DOMDocument();
847 $encoded = function_exists('mb_convert_encoding')
848 ? mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')
849 : $html;
850
851 libxml_use_internal_errors(true);
852 $dom->loadHTML('<div id="'.$wrap_id.'">'.$encoded.'</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
853 libxml_clear_errors();
854
855 $wrap = $dom->getElementById($wrap_id);
856 return [$dom, $wrap];
857 }
858
859 function atarim_dom_inner_html(DOMDocument $dom, DOMElement $wrap): string {
860 $out = '';
861 foreach ($wrap->childNodes as $child) {
862 $out .= $dom->saveHTML($child);
863 }
864 return html_entity_decode($out, ENT_QUOTES | ENT_HTML5, 'UTF-8');
865 }
866
867 function atarim_dom_outer_html(DOMDocument $dom, DOMNode $node): string {
868 return html_entity_decode($dom->saveHTML($node), ENT_QUOTES | ENT_HTML5, 'UTF-8');
869 }
870
871 function atarim_dom_children_by_tag(DOMNode $node, string $tag_upper): array {
872 $children = [];
873 foreach ($node->childNodes as $child) {
874 if ($child->nodeType === XML_ELEMENT_NODE && strtoupper($child->nodeName) === $tag_upper) {
875 $children[] = $child;
876 }
877 }
878 return $children;
879 }
880
881 function atarim_classic_find_node_outer_html(string $post_content, array $steps, string $tag_lower, string $snippet): ?string {
882 [$dom, $wrap] = atarim_dom_load_fragment($post_content, '__wrap__');
883 if (!$wrap) return null;
884
885 $current = $wrap;
886
887 foreach ($steps as $step) {
888 $children = atarim_dom_children_by_tag($current, $step['tag']);
889 $index = (int) $step['index'];
890 if (!isset($children[$index])) return null;
891 $current = $children[$index];
892 }
893
894 if (strtolower($current->nodeName) !== strtolower($tag_lower)) return null;
895
896 $node_text = atarim_normalize_text($current->textContent ?? '');
897 $snippet_norm = atarim_normalize_text($snippet);
898 if ($snippet_norm === '' || !str_contains($node_text, $snippet_norm)) return null;
899
900 return atarim_dom_outer_html($dom, $current);
901 }
902
903 function atarim_classic_replace_node_outer_html(string $post_content, array $steps, string $tag_lower, string $snippet, string $replacement_html): ?string {
904 [$dom, $wrap] = atarim_dom_load_fragment($post_content, '__wrap__');
905 if (!$wrap) return null;
906
907 $current = $wrap;
908
909 foreach ($steps as $step) {
910 $children = atarim_dom_children_by_tag($current, $step['tag']);
911 $index = (int) $step['index'];
912 if (!isset($children[$index])) return null;
913 $current = $children[$index];
914 }
915
916 if (strtolower($current->nodeName) !== strtolower($tag_lower)) return null;
917
918 $node_text = atarim_normalize_text($current->textContent ?? '');
919 $snippet_norm = atarim_normalize_text($snippet);
920 if ($snippet_norm === '' || !str_contains($node_text, $snippet_norm)) return null;
921
922 [$tmp_dom, $tmp_wrap] = atarim_dom_load_fragment($replacement_html, '__frag__');
923 if (!$tmp_wrap) return null;
924
925 $parent = $current->parentNode;
926 if (!$parent) return null;
927
928 foreach (iterator_to_array($tmp_wrap->childNodes) as $child) {
929 $parent->insertBefore($dom->importNode($child, true), $current);
930 }
931
932 $parent->removeChild($current);
933
934 return atarim_dom_inner_html($dom, $wrap);
935 }
936
937 /* ===========================
938 * REST: MEDIA IMPORT
939 * Push external file URLs (e.g. task/comment attachments) into the media
940 * library, unattached. Batch, with per-item error isolation so one bad file
941 * does not fail the rest.
942 * =========================== */
943 function atarim_inline_media_import_handler(WP_REST_Request $request) {
944
945 $items = $request->get_param('items');
946
947 // Be lenient: accept a bare url string, or a single { url } / { base64 } object.
948 if ( is_string($items) ) {
949 $items = [ [ 'url' => $items ] ];
950 } elseif ( is_array($items) && ( isset($items['url']) || isset($items['base64']) ) ) {
951 $items = [ $items ];
952 }
953
954 if ( ! is_array($items) || empty($items) ) {
955 return new WP_REST_Response(['status'=>false,'message'=>'Missing items: expected a non-empty array of { url | base64 }.'], 400);
956 }
957
958 if ( ! function_exists('media_handle_sideload') ) {
959 require_once ABSPATH . 'wp-admin/includes/file.php';
960 require_once ABSPATH . 'wp-admin/includes/media.php';
961 require_once ABSPATH . 'wp-admin/includes/image.php';
962 }
963
964 $results = [];
965
966 foreach ( $items as $item ) {
967 if ( ! is_array($item) ) {
968 $results[] = [ 'source' => '', 'success' => false, 'error' => 'Invalid item (expected an object with url or base64).' ];
969 continue;
970 }
971
972 $has_url = isset($item['url']) && trim( (string) $item['url'] ) !== '';
973 $has_b64 = isset($item['base64']) && trim( (string) $item['base64'] ) !== '';
974
975 if ( ! $has_url && ! $has_b64 ) {
976 $results[] = [ 'source' => '', 'success' => false, 'error' => 'Each item needs a url or base64.' ];
977 continue;
978 }
979 if ( $has_url && $has_b64 ) {
980 $results[] = [ 'source' => '', 'success' => false, 'error' => 'Provide only one of url or base64 per item.' ];
981 continue;
982 }
983
984 $source_ref = $has_url ? esc_url_raw( trim( (string) $item['url'] ) ) : '(base64)';
985 $filename = isset($item['filename']) ? sanitize_file_name( (string) $item['filename'] ) : '';
986
987 // Resolve the file to a temp path from whichever source was supplied.
988 if ( $has_url ) {
989 $url = esc_url_raw( trim( (string) $item['url'] ) );
990
991 // SSRF guard on the host.
992 $ssrf = atarim_media_import_check_url_safety($url);
993 if ( $ssrf !== null ) {
994 $results[] = [ 'source' => $url, 'success' => false, 'error' => $ssrf ];
995 continue;
996 }
997
998 // Fetch server-side, no redirects, with a size guard.
999 $fetched = atarim_media_import_fetch($url);
1000 if ( ! empty($fetched['error']) ) {
1001 $results[] = [ 'source' => $url, 'success' => false, 'error' => $fetched['error'] ];
1002 continue;
1003 }
1004 $tmp = $fetched['tmp'];
1005
1006 // Filename: explicit, else basename of the URL path.
1007 if ( $filename === '' ) {
1008 $path = wp_parse_url($url, PHP_URL_PATH);
1009 $filename = $path ? sanitize_file_name( basename($path) ) : '';
1010 }
1011 } else {
1012 // base64: a filename is required (we need an extension to validate type).
1013 if ( $filename === '' ) {
1014 $results[] = [ 'source' => $source_ref, 'success' => false, 'error' => 'filename is required for base64 items.' ];
1015 continue;
1016 }
1017
1018 $decoded = atarim_media_import_decode_base64( (string) $item['base64'] );
1019 if ( ! empty($decoded['error']) ) {
1020 $results[] = [ 'source' => $source_ref, 'success' => false, 'error' => $decoded['error'] ];
1021 continue;
1022 }
1023 $tmp = $decoded['tmp'];
1024 }
1025
1026 if ( $filename === '' ) {
1027 $filename = 'attachment';
1028 }
1029
1030 // Validate the type against the site's allowed MIME types.
1031 $filetype = wp_check_filetype_and_ext($tmp, $filename);
1032 if ( empty($filetype['type']) ) {
1033 @unlink($tmp);
1034 $results[] = [ 'source' => $source_ref, 'success' => false, 'error' => 'File type is not allowed on this site.' ];
1035 continue;
1036 }
1037 if ( ! empty($filetype['proper_filename']) ) {
1038 $filename = $filetype['proper_filename'];
1039 }
1040
1041 $file_array = [ 'name' => $filename, 'tmp_name' => $tmp ];
1042
1043 // Sideload into the library, unattached (post_id 0).
1044 $attachment_id = media_handle_sideload($file_array, 0);
1045
1046 if ( is_wp_error($attachment_id) ) {
1047 @unlink($tmp); // media_handle_sideload usually cleans up, but be safe.
1048 $results[] = [ 'source' => $source_ref, 'success' => false, 'error' => 'Import failed: ' . $attachment_id->get_error_message() ];
1049 continue;
1050 }
1051
1052 // Optional alt text / title.
1053 if ( ! empty($item['alt']) ) {
1054 update_post_meta($attachment_id, '_wp_attachment_image_alt', sanitize_text_field((string) $item['alt']));
1055 }
1056 if ( ! empty($item['title']) ) {
1057 wp_update_post([ 'ID' => $attachment_id, 'post_title' => sanitize_text_field((string) $item['title']) ]);
1058 }
1059
1060 $results[] = [
1061 'source' => $source_ref,
1062 'success' => true,
1063 'attachmentId' => (int) $attachment_id,
1064 'mediaUrl' => wp_get_attachment_url($attachment_id),
1065 'mimeType' => get_post_mime_type($attachment_id),
1066 'filename' => $filename,
1067 ];
1068 }
1069
1070 return new WP_REST_Response([ 'status' => true, 'results' => $results ], 200);
1071 }
1072
1073 /**
1074 * Fetch a URL to a temp file without following redirects, with a size guard.
1075 * Returns [ 'tmp' => path ] or [ 'error' => message ].
1076 */
1077 function atarim_media_import_fetch(string $url) {
1078 if ( ! function_exists('wp_tempnam') ) {
1079 require_once ABSPATH . 'wp-admin/includes/file.php';
1080 }
1081
1082 $resp = wp_remote_get($url, [ 'timeout' => 300, 'redirection' => 0 ]);
1083 if ( is_wp_error($resp) ) {
1084 return [ 'error' => 'Download failed: ' . $resp->get_error_message() ];
1085 }
1086
1087 $code = (int) wp_remote_retrieve_response_code($resp);
1088 if ( $code < 200 || $code >= 300 ) {
1089 return [ 'error' => sprintf('Download rejected (HTTP %d).', $code) ];
1090 }
1091
1092 $body = wp_remote_retrieve_body($resp);
1093 if ( $body === '' ) {
1094 return [ 'error' => 'Downloaded file is empty.' ];
1095 }
1096
1097 $max = wp_max_upload_size();
1098 if ( $max > 0 && strlen($body) > $max ) {
1099 return [ 'error' => sprintf('File exceeds the maximum upload size (%s).', size_format($max)) ];
1100 }
1101
1102 $tmp = wp_tempnam($url);
1103 if ( ! $tmp ) {
1104 return [ 'error' => 'Could not create a temporary file.' ];
1105 }
1106 if ( false === file_put_contents($tmp, $body) ) {
1107 @unlink($tmp);
1108 return [ 'error' => 'Could not write the downloaded file.' ];
1109 }
1110
1111 return [ 'tmp' => $tmp ];
1112 }
1113
1114 /**
1115 * Decode a base64 payload (optionally a data: URI) to a temp file, with a size
1116 * guard. Returns [ 'tmp' => path ] or [ 'error' => message ].
1117 */
1118 function atarim_media_import_decode_base64( $b64 ) {
1119 if ( ! function_exists('wp_tempnam') ) {
1120 require_once ABSPATH . 'wp-admin/includes/file.php';
1121 }
1122
1123 $b64 = (string) $b64;
1124 // Strip a data: URI prefix if present (e.g. "data:image/png;base64,....").
1125 if ( stripos( $b64, 'base64,' ) !== false ) {
1126 $b64 = substr( $b64, stripos( $b64, 'base64,' ) + 7 );
1127 }
1128 $b64 = trim( $b64 );
1129
1130 $decoded = base64_decode( $b64, true );
1131 if ( $decoded === false ) {
1132 return [ 'error' => 'Invalid base64 data.' ];
1133 }
1134 if ( $decoded === '' ) {
1135 return [ 'error' => 'Decoded file is empty.' ];
1136 }
1137
1138 $max = wp_max_upload_size();
1139 if ( $max > 0 && strlen( $decoded ) > $max ) {
1140 return [ 'error' => sprintf( 'File exceeds the maximum upload size (%s).', size_format( $max ) ) ];
1141 }
1142
1143 $tmp = wp_tempnam();
1144 if ( ! $tmp ) {
1145 return [ 'error' => 'Could not create a temporary file.' ];
1146 }
1147 if ( false === file_put_contents( $tmp, $decoded ) ) {
1148 @unlink( $tmp );
1149 return [ 'error' => 'Could not write the decoded file.' ];
1150 }
1151
1152 return [ 'tmp' => $tmp ];
1153 }
1154
1155 /**
1156 * SSRF guard for outbound fetches. Mirrors the media cluster's check: blocks
1157 * non-http(s) schemes, localhost, and hosts resolving to private/loopback/
1158 * link-local ranges (incl. the 169.254.169.254 metadata IP). Returns null when
1159 * safe, or an error string.
1160 */
1161 function atarim_media_import_check_url_safety($url) {
1162 $parsed = wp_parse_url($url);
1163 if ( ! is_array($parsed) || empty($parsed['scheme']) || empty($parsed['host']) ) {
1164 return 'Invalid URL — could not parse scheme and host.';
1165 }
1166
1167 $scheme = strtolower($parsed['scheme']);
1168 if ( $scheme !== 'http' && $scheme !== 'https' ) {
1169 return sprintf('URL scheme "%s" is not allowed — only http and https are supported.', $scheme);
1170 }
1171
1172 $host = strtolower($parsed['host']);
1173 if ( in_array($host, [ 'localhost', 'localhost.localdomain' ], true) ) {
1174 return 'Hostname "localhost" is not allowed.';
1175 }
1176
1177 $ips = @gethostbynamel($host);
1178 if ( ! is_array($ips) ) {
1179 if ( filter_var($host, FILTER_VALIDATE_IP) ) {
1180 $ips = [ $host ];
1181 } else {
1182 return sprintf('Could not resolve host "%s".', $host);
1183 }
1184 }
1185
1186 foreach ( $ips as $ip ) {
1187 if ( ! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) ) {
1188 return sprintf('URL host resolves to a blocked address (%s — private, loopback, or link-local range).', $ip);
1189 }
1190 if ( $ip === '169.254.169.254' ) {
1191 return 'URL host resolves to a cloud metadata endpoint (169.254.169.254) — blocked.';
1192 }
1193 }
1194
1195 return null;
1196 }