source_post_id); if (!$post || !current_user_can('edit_post', $post->ID)) return ['error' => __( 'You are not allowed to edit this post.', 'linkbraid' )]; $term_id = (int) ($s->target_term_id ?? 0); if ($term_id > 0) { // Taxonomy archive target: validated against the term URL map, not the posts table. $term = Linkbraid_Terms::get($term_id); if (!$term) return ['error' => __( 'Invalid target page.', 'linkbraid' )]; $url = Linkbraid_Content::resolve_url(!empty($s->custom_url) ? $s->custom_url : $term['url']); $resolved = $url ? Linkbraid_Terms::resolve($url) : null; if (!$url || !$resolved || (int) $resolved['term_id'] !== $term_id) return ['error' => __( 'The URL must point to the selected, published internal page.', 'linkbraid' )]; } else { $target = get_post($s->target_post_id); if (!$target || $target->post_status !== 'publish' || !in_array($target->post_type, lbraid_post_types(), true) || $target->ID === $post->ID) return ['error' => __( 'Invalid target page.', 'linkbraid' )]; if ($target->post_password !== '') return ['error' => __( 'The target page is password protected.', 'linkbraid' )]; if (!Linkbraid_Anchors::allows($target->ID, $s->anchor_text)) return ['error' => __( 'The target page requires an anchor from its assigned list. Edit the suggestion or run the analysis again.', 'linkbraid' )]; if (get_option('lbraid_exclude_home', 1) && $target->ID === (int) get_option('page_on_front')) return ['error' => __( 'The front page is excluded in the settings.', 'linkbraid' )]; if (in_array((int) $target->ID, lbraid_excluded_targets(), true)) return ['error' => __( 'This page is excluded from linking (the ignore list or a WooCommerce system page).', 'linkbraid' )]; $url = Linkbraid_Content::resolve_url(!empty($s->custom_url) ? $s->custom_url : get_permalink($target->ID)); if (!$url || (int) url_to_postid($url) !== (int) $target->ID) return ['error' => __( 'The URL must point to the selected, published internal page.', 'linkbraid' )]; } // A phrase assigned to a priority page never links anywhere else — also // when the anchor was edited by hand in the review dialog. if (!Linkbraid_Anchors::available_for((string) $s->anchor_text, $term_id > 0 ? 0 : (int) $s->target_post_id)) { return ['error' => __( 'This anchor is assigned to another priority page. Link that page instead, or pick a different anchor.', 'linkbraid' )]; } if (Linkbraid_Anchors::phrase_ignored((string) $s->anchor_text)) { return ['error' => __( 'This anchor is on the ignored-phrases list (Settings → Linking rules).', 'linkbraid' )]; } // The ignore list blocks the SOURCE side too: no new links inside ignored content. if (in_array((int) $post->ID, lbraid_ignored_posts(), true)) { return ['error' => __( 'This content is on the ignore list (Settings → Linking rules).', 'linkbraid' )]; } if (!empty($s->source_hash) && !hash_equals($s->source_hash, Linkbraid_Content::hash($post->ID))) return ['error' => __( 'The content has changed since the analysis. Run the analysis again.', 'linkbraid' )]; // The scanner sees the whole document (buttons, headings, image links) — // the fragment loop below only sees supported text. One URL per document. global $wpdb; $duplicate = $term_id > 0 ? $wpdb->get_var($wpdb->prepare("SELECT 1 FROM {$wpdb->prefix}lbraid_existing_links WHERE source_post_id = %d AND target_term_id = %d LIMIT 1", $post->ID, $term_id)) : $wpdb->get_var($wpdb->prepare("SELECT 1 FROM {$wpdb->prefix}lbraid_existing_links WHERE source_post_id = %d AND target_post_id = %d LIMIT 1", $post->ID, (int) $s->target_post_id)); if ($duplicate) return ['error' => __( 'This page is already linked in the content.', 'linkbraid' )]; $fragments = Linkbraid_Content::fragments($post->ID); $count = 0; foreach ($fragments as $fragment) foreach (Linkbraid_Content::links($fragment['html']) as $link) { $linked = (int) url_to_postid($link['url']); if (!$linked) { $linked_term = Linkbraid_Terms::resolve($link['url']); if (!$linked_term) continue; if ($term_id > 0 && (int) $linked_term['term_id'] === $term_id) return ['error' => __( 'This page is already linked in the content.', 'linkbraid' )]; $count++; continue; } if ($term_id === 0 && $linked === (int) $s->target_post_id) return ['error' => __( 'This page is already linked in the content.', 'linkbraid' )]; if ($linked !== (int) $post->ID && get_post_status($linked) === 'publish' && !(get_option('lbraid_exclude_home', 1) && $linked === (int) get_option('page_on_front'))) $count++; } if ($count >= max(1, (int) get_option('lbraid_max_links', 8))) return ['error' => __( 'The link limit for this post has been reached.', 'linkbraid' )]; $sentence = null; if (!empty($s->is_new_content)) { $sentence = Linkbraid_Content::insert(esc_html($s->suggested_sentence ?? ''), $s->anchor_text, $url); if ($sentence === null) return ['error' => __( 'The anchor does not occur in the suggested sentence.', 'linkbraid' )]; } foreach ($fragments as $fragment) { if ($sentence !== null) { if (empty($s->context_snippet) || !Linkbraid_Content::context_matches($fragment['html'], $s->context_snippet)) continue; $after = Linkbraid_Content::append_sentence($fragment['html'], $sentence); } else { if (!empty($s->context_snippet) && !Linkbraid_Content::context_matches($fragment['html'], $s->context_snippet)) continue; $after = Linkbraid_Content::insert($fragment['html'], $s->anchor_text, $url); } if ($after === null) continue; $snapshot = Linkbraid_Content::snapshot($post->ID); $next = $snapshot; $editor = self::detect_editor($post->ID); if ($editor === 'elementor') { $data = json_decode($snapshot['elementor'], true); $ref =& Linkbraid_Content::at($data, $fragment['path']); $ref = $after; $next['elementor'] = wp_json_encode($data); } elseif ($editor === 'gutenberg') { $blocks = parse_blocks($snapshot['content']); $ref =& Linkbraid_Content::at($blocks, $fragment['path']); $ref['innerHTML'] = $after; $ref['innerContent'] = [$after]; $next['content'] = serialize_blocks($blocks); } elseif ($editor === 'divi') { // path = ['divi', offset, length]: byte-precise substr_replace in post_content. $next['content'] = substr_replace($snapshot['content'], $after, (int) $fragment['path'][1], (int) $fragment['path'][2]); } else $next['content'] = $after; return ['before' => $fragment['html'], 'after' => $after, 'location' => $fragment['location'], 'url' => $url, 'snapshot' => $snapshot, 'next' => $next, 'editor' => $editor]; } return ['error' => __( 'No safe place to write was found. Supported: Gutenberg paragraphs and lists, Classic, and the Elementor text widget.', 'linkbraid' )]; } private static function write($id, $snapshot, $editor) { if ($editor === 'elementor') { $ok = update_post_meta($id, '_elementor_data', wp_slash($snapshot['elementor'])); delete_post_meta($id, '_elementor_css'); // Elementor serves cached HTML independently of its CSS and WP's post cache. // Invalidate on both apply and undo, otherwise visitors still see the old links. delete_post_meta($id, '_elementor_element_cache'); } else $ok = wp_update_post(wp_slash(['ID' => $id, 'post_content' => $snapshot['content']]), true); clean_post_cache($id); return !is_wp_error($ok) && $ok && Linkbraid_Content::snapshot($id) === $snapshot; } public static function apply_suggestion($id) { global $wpdb; $table = $wpdb->prefix . 'lbraid_link_suggestions'; $s = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE id = %d", $id)); if (!$s || $s->status !== 'pending') return ['error' => __( 'The suggestion is no longer pending.', 'linkbraid' )]; $lock = Linkbraid_Jobs::lock('post_' . $s->source_post_id); if (!$lock) return ['error' => __( 'Another operation is changing this post. Try again.', 'linkbraid' )]; try { $s = $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE id = %d", $id)); if (!$s || $s->status !== 'pending') return ['error' => __( 'The suggestion has already been processed.', 'linkbraid' )]; $plan = self::plan($s); if (isset($plan['error'])) return $plan; if (!Linkbraid_Jobs::owns_lock($lock)) return ['error' => __( 'The post lock has expired. Try again.', 'linkbraid' )]; $history = ['suggestion_id' => (int) $id, 'before' => $plan['snapshot'], 'after_hash' => hash('sha256', wp_json_encode($plan['next'])), 'editor' => $plan['editor'], 'date' => current_time('mysql'), 'user' => get_current_user_id(), 'state' => 'prepared']; $history_id = add_post_meta($s->source_post_id, '_lbraid_link_history', wp_slash($history)); if (!$history_id) return ['error' => __( 'The content backup could not be saved. The change was stopped.', 'linkbraid' )]; if (Linkbraid_Content::snapshot($s->source_post_id) !== $plan['snapshot']) return ['error' => __( 'The post changed before saving. Run the analysis again.', 'linkbraid' )]; if (!self::write($s->source_post_id, $plan['next'], $plan['editor'])) { $history['state'] = 'failed'; update_metadata_by_mid('post', $history_id, $history); return ['error' => __( 'The save was not confirmed. Check the post content; a backup is kept in the history.', 'linkbraid' )]; } $history['state'] = 'applied'; update_metadata_by_mid('post', $history_id, $history); if (false === Linkbraid_Database::update_suggestion_status($id, 'accepted')) return ['error' => __( 'The link was saved, but the status update failed. Refresh the panel.', 'linkbraid' )]; $old_hash = hash('sha256', wp_json_encode($plan['snapshot'])); $wpdb->update($table, ['source_hash' => $history['after_hash']], ['source_post_id' => $s->source_post_id, 'source_hash' => $old_hash, 'status' => 'pending']); Linkbraid_Database::refresh_existing_links($s->source_post_id); return ['success' => true, 'message' => __( 'Link saved. The change can be undone from the history.', 'linkbraid' )]; } finally { Linkbraid_Jobs::unlock('post_' . $s->source_post_id, $lock); } } public static function undo($post_id, $meta_id) { if (!current_user_can('edit_post', $post_id)) return ['error' => __( 'Insufficient permissions.', 'linkbraid' )]; $lock = Linkbraid_Jobs::lock('post_' . $post_id); if (!$lock) return ['error' => __( 'The post is being processed.', 'linkbraid' )]; try { $meta = get_metadata_by_mid('post', $meta_id); if (!$meta || (int) $meta->post_id !== (int) $post_id || $meta->meta_key !== '_lbraid_link_history') return ['error' => __( 'The change was not found.', 'linkbraid' )]; $h = $meta->meta_value; if (!empty($h['suggestion_id'])) { global $wpdb; $source = (int) $wpdb->get_var($wpdb->prepare("SELECT source_post_id FROM {$wpdb->prefix}lbraid_link_suggestions WHERE id = %d", $h['suggestion_id'])); // An entry copied along with a duplicated post belongs to its source. if ($source && $source !== (int) $post_id) return ['error' => __( 'The change was not found.', 'linkbraid' )]; } if ($h['state'] !== 'applied' || !hash_equals($h['after_hash'], Linkbraid_Content::hash($post_id))) return ['error' => __( 'The content changed afterwards. Undo the newer changes first, or restore the content manually.', 'linkbraid' )]; if (!self::write($post_id, $h['before'], $h['editor'])) return ['error' => __( 'The undo could not be confirmed.', 'linkbraid' )]; $h['state'] = 'undone'; update_metadata_by_mid('post', $meta_id, $h); Linkbraid_Database::update_suggestion_status($h['suggestion_id'], 'reverted'); Linkbraid_Database::refresh_existing_links($post_id); return ['success' => true]; } finally { Linkbraid_Jobs::unlock('post_' . $post_id, $lock); } } public static function apply_bulk($ids) { $out = ['success' => 0, 'failed' => 0, 'errors' => []]; foreach (array_unique(array_map('intval', $ids)) as $id) { $result = self::apply_suggestion($id); if (!empty($result['success'])) $out['success']++; else { $out['failed']++; $out['errors'][] = sprintf( '#%d: %s', $id, $result['error'] ?? __( 'Save error', 'linkbraid' ) ); } } return $out; } public static function reject_suggestion($id) { return Linkbraid_Database::update_suggestion_status($id, 'rejected'); } /** * Removes every link to $url from the supported fragments of the posts that * contain it, keeping the anchor text. Each write goes through the same * snapshot + history pipeline as applying a suggestion, so it can be undone. */ public static function unlink_url($url) { global $wpdb; $sources = $wpdb->get_col($wpdb->prepare("SELECT DISTINCT source_post_id FROM {$wpdb->prefix}lbraid_external_links WHERE url = %s", $url)); if (!$sources) return ['error' => __( 'No content containing this link was found. Run a link scan first.', 'linkbraid' )]; $changed = 0; $skipped = []; foreach (array_map('intval', $sources) as $post_id) { $result = self::unlink_in_post($post_id, $url); if (!empty($result['changed'])) $changed++; else $skipped[] = get_the_title($post_id) ?: ('#' . $post_id); } return ['success' => true, 'changed' => $changed, 'skipped' => array_values($skipped)]; } private static function unlink_in_post($post_id, $url) { return self::rewrite_in_post($post_id, function ($html) use ($url) { return Linkbraid_Content::unlink($html, $url); }); } /** * Changes every link to $old_url into $new_url across the whole site, * keeping the anchor text. Same snapshot + history pipeline as everything * else, so every touched post can be undone individually. */ public static function change_url($old_url, $new_url) { $old = esc_url_raw(trim((string) $old_url)); $new = esc_url_raw(trim((string) $new_url)); if (!$old || !$new) return ['error' => __( 'Enter both addresses: the old one and the new one.', 'linkbraid' )]; if (untrailingslashit(strtolower($old)) === untrailingslashit(strtolower($new))) return ['error' => __( 'The old and the new address are the same.', 'linkbraid' )]; global $wpdb; $sources = $wpdb->get_col($wpdb->prepare("SELECT DISTINCT source_post_id FROM {$wpdb->prefix}lbraid_external_links WHERE url = %s", $old)); $target_id = (int) url_to_postid($old); if ($target_id) { $sources = array_merge($sources, $wpdb->get_col($wpdb->prepare("SELECT DISTINCT source_post_id FROM {$wpdb->prefix}lbraid_existing_links WHERE target_post_id = %d", $target_id))); } $sources = array_values(array_unique(array_map('intval', $sources))); if (!$sources) return ['error' => __( 'No content containing this link was found. Run a link scan first.', 'linkbraid' )]; $changed = 0; $skipped = []; foreach ($sources as $post_id) { $result = self::rewrite_in_post($post_id, function ($html) use ($old, $new) { return Linkbraid_Content::replace_url($html, $old, $new); }); if (!empty($result['changed'])) $changed++; else $skipped[] = get_the_title($post_id) ?: ('#' . $post_id); } return ['success' => true, 'changed' => $changed, 'skipped' => array_values($skipped)]; } /** Shared fragment rewriter: lock, snapshot, history with undo, rescan. */ private static function rewrite_in_post($post_id, $rewrite) { $post = get_post($post_id); if (!$post || !current_user_can('edit_post', $post_id)) return ['changed' => false]; $lock = Linkbraid_Jobs::lock('post_' . $post_id); if (!$lock) return ['changed' => false]; $changed = false; try { // One fragment per write; repeat while other fragments still match. for ($round = 0; $round < 5; $round++) { $written = false; foreach (Linkbraid_Content::fragments($post_id) as $fragment) { $after = $rewrite($fragment['html']); if ($after === null) continue; while (($more = $rewrite($after)) !== null) $after = $more; $snapshot = Linkbraid_Content::snapshot($post_id); $next = $snapshot; $editor = self::detect_editor($post_id); if ($editor === 'elementor') { $data = json_decode($snapshot['elementor'], true); $ref =& Linkbraid_Content::at($data, $fragment['path']); $ref = $after; $next['elementor'] = wp_json_encode($data); } elseif ($editor === 'gutenberg') { $blocks = parse_blocks($snapshot['content']); $ref =& Linkbraid_Content::at($blocks, $fragment['path']); $ref['innerHTML'] = $after; $ref['innerContent'] = [$after]; $next['content'] = serialize_blocks($blocks); } elseif ($editor === 'divi') { $next['content'] = substr_replace($snapshot['content'], $after, (int) $fragment['path'][1], (int) $fragment['path'][2]); } else { $next['content'] = $after; } if (!Linkbraid_Jobs::owns_lock($lock)) return ['changed' => $changed]; $history = ['suggestion_id' => 0, 'before' => $snapshot, 'after_hash' => hash('sha256', wp_json_encode($next)), 'editor' => $editor, 'date' => current_time('mysql'), 'user' => get_current_user_id(), 'state' => 'prepared']; $history_id = add_post_meta($post_id, '_lbraid_link_history', wp_slash($history)); if (!$history_id) return ['changed' => $changed]; if (Linkbraid_Content::snapshot($post_id) !== $snapshot) return ['changed' => $changed]; if (!self::write($post_id, $next, $editor)) { $history['state'] = 'failed'; update_metadata_by_mid('post', $history_id, $history); return ['changed' => $changed]; } $history['state'] = 'applied'; update_metadata_by_mid('post', $history_id, $history); $changed = true; $written = true; break; } if (!$written) break; } if ($changed) Linkbraid_Database::refresh_existing_links($post_id); return ['changed' => $changed]; } finally { Linkbraid_Jobs::unlock('post_' . $post_id, $lock); } } }