int,'post_content'=>string] */ public static $linkers = null; /** fn(array $post_ids): array of post_id => array of link records */ public static $ledger = null; /** Row ceiling for one link count. Bounds memory: the query selects content. */ const MAX_SCAN = 2000; /** Set when a count hit the ceiling, so the figure is reported as a floor. */ public static $truncated = false; /** * Execution state for one cluster. * * @param array $pillar ['post_id'=>int,'title'=>string,'url'=>string] * @param array $entries Diary entries belonging to this cluster. * @param array $opts ['post_types'=>string[]] */ public static function summarise($pillar, $entries, $opts = []) { $types = $opts['post_types'] ?? ['post']; $pillar_id = (int) ($pillar['post_id'] ?? 0); self::$truncated = false; // Spoke post IDs, so a link can be classified by where it came FROM. $spoke_ids = []; foreach ($entries as $e) { $pid = (int) ($e['post_id'] ?? 0); if ($pid) $spoke_ids[$pid] = true; } // ── Live inbound links to the pillar ──────────────────────────────── // One query, not a graph. Counts every link that actually exists today, // including ones the user made by hand, which is why this is read live // rather than taken from the ledger. // AICOBR_PILLAR_URL_FORMS_2026_08 // Count links AS THEY WERE WRITTEN, not as the pillar resolves today. // // A spoke's up-link is written at generation time using the URL stored // on the entry's cluster_pillar. The report resolves the pillar with // get_permalink(), which can return a DIFFERENT form: a page later set // as the site's front page resolves to the site root, while the links // already in the catalogue still point at its slug. The two never match // and every up-link becomes invisible, which is exactly how a cluster // with a published spoke reported "0 up from spokes" while the link was // plainly there in the post. // // So every known form of the pillar's address counts. $pillar_urls = array_values(array_unique(array_filter(array_merge( [(string) ($pillar['url'] ?? '')], (array) ($pillar['alt_urls'] ?? []) )))); // Deduped here as well as inside linkers_to. A source that links to two // forms of the same pillar is ONE inbound link, and the invariant should // not depend on which code path produced the list: the injected // repository dedupes by array key, the SQL path by an early break, and // neither is visible from here. $linkers = array_values(array_unique(array_map('intval', self::linkers_to($pillar_urls, $types)))); $from_catalogue = 0; $from_spokes = 0; foreach ($linkers as $src) { if (isset($spoke_ids[(int) $src])) $from_spokes++; else $from_catalogue++; } // ── Sideways: spoke linking to spoke ──────────────────────────────── // Counted because it is invisible otherwise and it competes with the // stated goal. A link from one spoke to another stays inside the // cluster instead of reaching the pillar. This is reported, NOT // condemned: some sibling linking is genuinely useful to a reader. The // number only becomes a signal when it grows relative to upward links. // // AICOBR_SIDEWAYS_ACCURACY_2026_08 // Two things this must not do. // // 1. Count a post linking to ITSELF. The old test was only "is the // source a spoke", which a self-link satisfies. ACB never writes // one, but a hand-written one would silently inflate the figure. // // 2. Report a link to an UNPUBLISHED sibling as a healthy sideways // link. That link 404s for every reader, so counting it alongside // working links hides a broken one. It is separated out and // reported as `sideways_to_unpublished`, which is a problem the // user can act on rather than a statistic. $sideways = 0; $sideways_dead = 0; foreach ($entries as $e) { $pid = (int) ($e['post_id'] ?? 0); $url = (string) ($e['post_url'] ?? ''); $live = (($e['wp_status'] ?? '') === 'publish'); if (!$pid || $url === '') continue; foreach (self::linkers_to([$url], $types) as $src) { $src = (int) $src; if (!isset($spoke_ids[$src])) continue; // not a cluster post if ($src === $pid) continue; // a post linking to itself if ($live) $sideways++; else $sideways_dead++; } } // ── Brief states ──────────────────────────────────────────────────── $published = 0; $written = 0; $planned = 0; foreach ($entries as $e) { $generated = (($e['status'] ?? '') === 'generated'); $live = (($e['wp_status'] ?? '') === 'publish'); if ($generated && $live) $published++; elseif ($generated) $written++; else $planned++; } // ── The ledger ────────────────────────────────────────────────────── // AICOBR_LEDGER_SCOPE_2026_08 // // Scope comes from the TARGET, not the source. Inbound linking writes // into the site's existing back catalogue, so a source post is almost // never a cluster member — filtering by source would return nothing for // exactly the feature this card reports on. The question the card asks // ("Links ACB inserted ... anywhere in this cluster") is about links // POINTING AT the pillar or one of its spokes, whatever wrote them. // // array_merge, NOT `+`. Array union merges by KEY, so // array_keys($spoke_ids) + [$pillar_id] silently discarded the pillar // whenever the cluster had at least one spoke — both lists start at key // 0. A link to the pillar is the most valuable thing this feature // produces, so dropping it was the worst possible thing to drop. $cluster_ids = array_keys($spoke_ids); if ($pillar_id) $cluster_ids[] = $pillar_id; $cluster_ids = array_values(array_unique(array_map('intval', $cluster_ids))); $in_cluster = array_fill_keys($cluster_ids, true); // Filtering happens HERE rather than in the fetch, so the guarantee holds // identically for the wpdb path and for an injected repository. The same // reasoning as the inbound dedupe above: a correctness rule that lives in // only one of two interchangeable data paths is not a rule. $records = self::ledger_for($cluster_ids); $built = 0; $broken = 0; $recent = []; // AICOBR_CLUSTER_SOURCES_2026_08 — the individual source→target links, so // the report can list WHICH pages link in, not just a per-day count. Kept // WP-free here (no title/permalink lookups); the handler resolves those // live, the same way it resolves recent[].target_title. $sources = []; $sources_cap = 50; $sources_truncated = false; foreach ($records as $source_id => $links) { foreach ((array) $links as $l) { $tid = (int) ($l['target_post_id'] ?? 0); if (!isset($in_cluster[$tid])) continue; // another cluster's link $built++; $day = substr((string) ($l['inserted_at'] ?? ''), 0, 10); $k = $day . '|' . $tid; if (!isset($recent[$k])) { $recent[$k] = [ 'date' => $day, 'target_post_id' => $tid, 'target_title' => (string) ($l['target_title'] ?? ''), 'count' => 0, ]; } $recent[$k]['count']++; if (!empty($l['_missing'])) $broken++; if (count($sources) < $sources_cap) { $sources[] = [ 'source_id' => (int) $source_id, 'target_post_id' => $tid, 'target_title' => (string) ($l['target_title'] ?? ''), 'target_url' => (string) ($l['target_url'] ?? ''), 'anchor_text' => (string) ($l['anchor_text'] ?? ''), 'inserted_at' => (string) ($l['inserted_at'] ?? ''), 'missing' => !empty($l['_missing']), // ledger_for computes this live ]; } else { $sources_truncated = true; } } } usort($recent, fn($a, $b) => strcmp($b['date'], $a['date'])); return [ 'pillar' => [ 'post_id' => $pillar_id, 'title' => (string) ($pillar['title'] ?? ''), 'url' => (string) ($pillar['url'] ?? ''), ], // Links pointing at the pillar RIGHT NOW. This is the headline and // it is the only number the user asked to watch. 'inbound' => [ 'total' => $from_catalogue + $from_spokes, 'from_catalogue' => $from_catalogue, 'from_spokes' => $from_spokes, ], 'sideways' => $sideways, // AICOBR_SIDEWAYS_ACCURACY_2026_08 — cluster posts linking to a // sibling that is NOT published. Each one is a 404 for readers. 'sideways_to_unpublished' => $sideways_dead, // AICOBR_FRONT_PAGE_PILLAR_2026_08 — true when a count hit the row // ceiling, so every inbound figure is a minimum rather than a total. 'truncated' => self::$truncated, 'briefs' => [ 'total' => count($entries), 'published' => $published, 'written' => $written, // generated, not published 'planned' => $planned, // not generated ], 'ledger' => [ 'built' => $built, 'broken' => $broken, // ACB wrote it; it is not there now 'recent' => array_slice(array_values($recent), 0, 10), // AICOBR_CLUSTER_SOURCES_2026_08 — the pages that link into this // cluster, capped. Titles/edit/view links resolved live in the // handler; `missing` is ledger_for's live presence check. 'sources' => $sources, 'sources_truncated' => $sources_truncated, ], ]; } /** * Post IDs whose CURRENT content links to $url. * * Live rather than from the ledger, because the user's own hand-made links * count towards the pillar just as much as ACB's, and a state view that * only saw ACB's work would understate the site. */ protected static function linkers_to($urls, $types) { $urls = array_values(array_filter(array_map('strval', (array) $urls))); if (!$urls) return []; if (is_callable(static::$linkers)) { // Injected repositories are given one URL at a time and their // results merged, so a test fixture stays a simple url => ids map. $out = []; foreach ($urls as $u) { foreach ((array) call_user_func(static::$linkers, $u, $types) as $id) { $out[(int) $id] = true; } } return array_keys($out); } global $wpdb; $types = array_values(array_filter(array_map('sanitize_key', (array) $types))); if (!$types) $types = ['post']; $ph = implode(',', array_fill(0, count($types), '%s')); // Narrow in SQL, then confirm with the same links_to() the scanner uses, // so trailing-slash and entity handling cannot disagree between what // this counts and what the linker believes. // // AICOBR_FRONT_PAGE_PILLAR_2026_08 // A front-page pillar has NO PATH, so narrowing on the path produced // LIKE '%%', which matches every row. Combined with the row ceiling that // silently turned the count into "the first N posts that happen to // match", reported as if it were the total. Narrow on the host instead // when there is no path: every internal absolute link contains it, so it // is still a wide net, but an honest one that the ceiling now reports. $likes = []; $vals = []; foreach ($urls as $u) { $path = trim((string) parse_url($u, PHP_URL_PATH), '/'); $needle = $path !== '' ? $path : (string) parse_url($u, PHP_URL_HOST); if ($needle === '') continue; $likes[] = 'post_content LIKE %s'; $vals[] = '%' . $wpdb->esc_like($needle) . '%'; } if (!$likes) return []; $rows = $wpdb->get_results($wpdb->prepare( "SELECT ID, post_content FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ($ph) AND (" . implode(' OR ', $likes) . ") LIMIT " . (self::MAX_SCAN + 1), array_merge($types, $vals) ), ARRAY_A) ?: []; // Ask for one more than the ceiling so "there were more" can be told // apart from "exactly that many", the same probe the candidate scan uses. if (count($rows) > self::MAX_SCAN) { self::$truncated = true; $rows = array_slice($rows, 0, self::MAX_SCAN); } $out = []; foreach ($rows as $r) { foreach ($urls as $u) { if (AICOBR_Link_Scanner::links_to((string) $r['post_content'], $u)) { $out[] = (int) $r['ID']; break; // one source, counted once } } } return $out; } /** * ACB's own record of links it inserted, per SOURCE post, with each record * flagged _missing when the exact stored HTML is no longer in the post. * * AICOBR_LEDGER_SCOPE_2026_08 * * $target_ids is the cluster the caller cares about (pillar plus spokes). It * deliberately does NOT narrow this query, and that is not an oversight: * _aicobr_inbound_links lives on the SOURCE post, and a source is an ordinary * catalogue post that belongs to no cluster. Narrowing the fetch by these ids * would return almost nothing. The rows are therefore read site-wide and * scoped by target in summarise(), which keeps one filter serving both this * path and an injected repository. * * The argument is still passed and honoured as an early-out, so a caller with * an empty cluster does no query at all. */ protected static function ledger_for($target_ids) { $target_ids = array_values(array_unique(array_filter(array_map('intval', (array) $target_ids)))); if (!$target_ids) return []; if (is_callable(static::$ledger)) { return (array) call_user_func(static::$ledger, $target_ids); } global $wpdb; // prepare() with a placeholder for the meta key. The previous form passed // an empty argument list to a query containing no placeholders, which WP // reports via _doing_it_wrong on every call. $rows = $wpdb->get_results($wpdb->prepare( "SELECT p.ID, p.post_content, m.meta_value FROM {$wpdb->posts} p JOIN {$wpdb->postmeta} m ON m.post_id = p.ID WHERE m.meta_key = %s LIMIT %d", '_aicobr_inbound_links', self::MAX_SCAN ), ARRAY_A) ?: []; $out = []; foreach ($rows as $r) { $links = maybe_unserialize($r['meta_value']); if (!is_array($links)) continue; $keep = []; foreach ($links as $l) { if (!is_array($l)) continue; $stored = (string) ($l['stored_html'] ?? ''); // The ledger says ACB wrote this. Live content decides whether // it is still there. Reported, never silently corrected. $l['_missing'] = ($stored !== '' && strpos((string) $r['post_content'], $stored) === false); $keep[] = $l; } if ($keep) $out[(int) $r['ID']] = $keep; } return $out; } }