. "Safely"
* carries a lot of weight — a naive str_replace over 300 hand-written posts
* would corrupt shortcode attributes, nest anchors, rewrite alt text and break
* Gutenberg block delimiters, and the user would not discover it for weeks.
*
* Two lessons are inherited from the in-body image work in the main plugin:
*
* 1. Never assume
exists. Classic-editor content stores paragraphs as
* bare text between block elements and lets wpautop() create the
at
* render time — a real post can contain zero
in post_content. This is
* the same trap documented at aicobr_prose_anchor_offset(); a linker that
* anchors on paragraphs silently no-ops on exactly the older hand-written
* posts this feature exists to serve.
*
* 2. Apply edits from the HIGHEST offset backwards, so earlier offsets stay
* valid as the string grows. Same discipline as aicobr_insert_figure().
*
* Offsets are BYTE offsets, consistent with preg_* PREG_OFFSET_CAPTURE and with
* aicobr_find_headings() in the main plugin. Multibyte safety comes from every
* pattern carrying the /u flag, so a match can only ever begin and end on a
* character boundary — never from mb_* string juggling, which would disagree
* with the rest of the codebase.
*/
if (!class_exists('AICOBR_Link_Scanner')) {
class AICOBR_Link_Scanner {
/**
* Elements whose ENTIRE contents are off limits, not merely their tags.
*
* a — anchors cannot nest, and re-linking existing link text is
* both invalid HTML and an over-optimisation signal.
* h1..h6 — linking a heading reads as spam and breaks the visual
* hierarchy the formatter builds.
* figure — covers , including ACB's own acb-fig captions.
* code/pre/ — a keyword inside a code sample is not prose.
* kbd/samp
* script/ — self-evident; also protects inline JSON.
* style
* blockquote — inserting a link into quoted material silently alters
* somebody else's words. Editorially wrong rather than
* technically unsafe, so it is filterable, but the default
* is to leave quotations alone.
* textarea/ — these hold TEXT that is not prose. An inside \s*$#i', '', $text);
if ($text !== '') $out[] = $text;
}
return $out;
}
/**
* AICOBR_REFRESH_CLUSTER_2026_08 — the FULL … HTML (and its text) of
* the first link in $html that points at $url, or null. Same tag-pairing and
* href matching as anchor_texts_to(), so the exact substring recorded as a
* ledger entry is what the ledger's presence check (strpos of stored_html)
* will later look for.
*/
public static function first_anchor_to($html, $url) {
$html = (string) $html;
$needle = rtrim((string) $url, '/');
if ($html === '' || $needle === '') return null;
$path = null;
if (preg_match('#^https?://[^/]+(/.*)$#i', $needle, $pm)) {
$path = rtrim($pm[1], '/');
}
$ends = function ($c) {
return $c === '"' || $c === "'" || $c === '?' || $c === '#' || $c === '';
};
foreach (self::paired_ranges($html, 'a') as [$start, $end]) {
$anchor = substr($html, $start, $end - $start);
$gt = strpos($anchor, '>');
if ($gt === false) continue;
$open = substr($anchor, 0, $gt + 1);
if (!preg_match('/\bhref\s*=\s*("|\')(.*?)\1/isu', $open, $hm)) continue;
$href = rtrim(html_entity_decode($hm[2], ENT_QUOTES, 'UTF-8'), '/');
$matched = false;
foreach (array_filter([$needle, $path]) as $cand) {
if ($href === $cand) { $matched = true; break; }
if (strpos($href, $cand) === 0) {
$after = substr($href, strlen($cand), 1);
if ($ends($after)) { $matched = true; break; }
}
}
if (!$matched) continue;
$text = trim(self::flatten(substr($anchor, $gt + 1)));
$text = preg_replace('#\s*\s*$#i', '', $text);
return ['html' => $anchor, 'text' => $text];
}
return null;
}
/**
* Apply link insertions to content.
*
* @param array $insertions Each ['start'=>int,'end'=>int,'url'=>string,
* 'title'=>?string,'class'=>?string]
*
* Applied from the highest offset DOWNWARDS so that every earlier offset
* remains valid as the string grows — the same reason aicobr_insert_figure()
* works backwards. Overlapping insertions are dropped rather than merged:
* two links claiming the same words is a caller bug, and silently producing
* nested anchors would be worse than refusing one of them.
*/
public static function apply_links($html, array $insertions) {
$html = (string) $html;
if (!$insertions) return $html;
usort($insertions, fn($a, $b) => $b['start'] <=> $a['start']);
$lastStart = null;
foreach ($insertions as $ins) {
$start = (int) $ins['start'];
$end = (int) $ins['end'];
if ($start < 0 || $end > strlen($html) || $end <= $start) continue;
if ($lastStart !== null && $end > $lastStart) continue; // overlaps
$anchor = substr($html, $start, $end - $start);
$html = substr($html, 0, $start)
. self::build_anchor($ins['url'], $anchor, $ins['title'] ?? '', $ins['class'] ?? '')
. substr($html, $end);
$lastStart = $start;
}
return $html;
}
/**
* The ACB writes.
*
* Carries a class so its links are identifiable in content later — for
* counting, for reporting, and above all for undo, which must be able to
* find exactly what ACB wrote and nothing a user wrote by hand.
*/
public static function build_anchor($url, $text, $title = '', $class = 'acb-ilink') {
$class = $class !== '' ? $class : 'acb-ilink';
$attrs = 'href="' . self::esc_attr($url) . '" class="' . self::esc_attr($class) . '"';
if ($title !== '') $attrs .= ' title="' . self::esc_attr($title) . '"';
return '' . $text . '';
}
/**
* Reverse one ACB link, restoring the original anchor text.
*
* Matched on the exact stored HTML. If it is not found the post has been
* edited since insertion, and this REFUSES rather than falling back to a
* looser pattern — a blind regex over content a user has since rewritten is
* precisely the failure this feature must not have.
*
* @return string|false New content, or false when the link was not found.
*/
public static function remove_link($html, $stored_html) {
$html = (string) $html;
$stored_html = (string) $stored_html;
if ($stored_html === '') return false;
$at = strpos($html, $stored_html);
if ($at === false) return false;
if (!preg_match('/^]*>(.*)<\/a>$/su', $stored_html, $m)) return false;
return substr($html, 0, $at) . $m[1] . substr($html, $at + strlen($stored_html));
}
/**
* Plain-text excerpt around a match, for the approval UI.
*
* Seeing the sentence a link will land in is what makes review meaningful
* rather than a list of post titles clicked through blindly. Tags are
* stripped for display and the anchor is wrapped in , so the caller
* renders a highlight without re-deriving the offset.
*
* mb_* is used HERE and only here: this is display text that gets truncated
* at an arbitrary width, so it is the one place a naive byte cut could split
* a character and emit mojibake.
*/
public static function excerpt($html, $start, $end, $pad = 70) {
$before = self::flatten(substr($html, 0, $start));
$anchor = self::flatten(substr($html, $start, $end - $start));
$after = self::flatten(substr($html, $end));
$bLen = self::mb_len($before);
$lead = $bLen > $pad ? '…' . self::mb_cut($before, $bLen - $pad, $pad) : $before;
$tail = self::mb_len($after) > $pad ? self::mb_cut($after, 0, $pad) . '…' : $after;
return trim($lead) . '' . $anchor . '' . rtrim($tail);
}
/**
* AICOBR_RESCAN_CONTEXT_2026_08
*
* A normalised fingerprint of the text immediately around a matched range,
* used to identify WHICH occurrence of a phrase the user reviewed. Built from
* flattened display text (tags out, entities decoded, whitespace collapsed)
* and lower-cased — the SAME text the excerpt shows — so it is stable across
* cosmetic markup changes but sensitive to real edits of the surrounding
* words.
*
* Produced at review time (travels with the suggestion) and recomputed at
* insert time; identity is "same window on both sides". Producer and consumer
* MUST use the same $window.
*
* @return array{before:string, after:string}
*/
public static function occurrence_context($html, $start, $end, $window = 48) {
$html = (string) $html;
$start = max(0, (int) $start);
$end = max($start, (int) $end);
$before = self::flatten(substr($html, 0, $start));
$after = self::flatten(substr($html, $end));
$bLen = self::mb_len($before);
$before = $bLen > $window ? self::mb_cut($before, $bLen - $window, $window) : $before;
$after = self::mb_len($after) > $window ? self::mb_cut($after, 0, $window) : $after;
return ['before' => self::ctx_norm($before), 'after' => self::ctx_norm($after)];
}
/** Normalise a context window: collapse whitespace, lower-case, trim. */
private static function ctx_norm($s) {
$s = preg_replace('/\s+/u', ' ', (string) $s);
$s = function_exists('mb_strtolower') ? mb_strtolower($s, 'UTF-8') : strtolower($s);
return trim($s);
}
/**
* AICOBR_RESCAN_CONTEXT_2026_08
*
* Re-find, in CURRENT content, the specific SAFE occurrence of $phrase the
* user reviewed — identified by $context (what occurrence_context() returned
* at review time). Returns the match ['start','end','text'] only when a
* currently-safe occurrence has the SAME surrounding context; returns null
* otherwise: the occurrence was deleted, has moved into an unsafe spot
* (heading, existing link, …), or the words around it were edited.
*
* This is the insert-time guard that makes placement correct-or-nothing. The
* byte offset cached at scan time is never trusted — the phrase is re-located
* against live content and a link is only ever written where the reviewed
* sentence still is. No match → the caller writes nothing and tells the user
* to re-scan.
*
* @param array{before?:string, after?:string} $context
* @return array{start:int,end:int,text:string}|null
*/
public static function find_reviewed_occurrence($html, $phrase, $context, $window = 48, array $extenders = []) {
$wantBefore = self::ctx_norm($context['before'] ?? '');
$wantAfter = self::ctx_norm($context['after'] ?? '');
// AICOBR_NAME_GUARD_2026_08 — the same $extenders the scan used. Without
// this the guard would be advisory: a suggestion produced before a sibling
// post existed could still be inserted afterwards, splitting a name the
// scan would now refuse. Guarding both paths keeps "what you reviewed is
// what gets written" true in both directions.
foreach (self::find_matches($html, $phrase, 0, $extenders) as $cand) {
$cur = self::occurrence_context($html, $cand['start'], $cand['end'], $window);
if ($cur['before'] === $wantBefore && $cur['after'] === $wantAfter) {
return $cand;
}
}
return null;
}
/** Tags and entities out, whitespace collapsed — display text only. */
private static function flatten($html) {
$t = preg_replace('/<[^>]*>/su', ' ', (string) $html);
$t = html_entity_decode($t, ENT_QUOTES, 'UTF-8');
return preg_replace('/\s+/u', ' ', $t);
}
private static function mb_len($s) {
return function_exists('mb_strlen') ? mb_strlen($s, 'UTF-8') : strlen($s);
}
private static function mb_cut($s, $from, $len) {
return function_exists('mb_substr') ? mb_substr($s, $from, $len, 'UTF-8') : substr($s, $from, $len);
}
/** Local esc_attr so this file stays testable outside WordPress. */
private static function esc_attr($s) {
return function_exists('esc_attr')
? esc_attr($s)
: htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
}
}
}