| 1 |
<?php |
| 2 |
/** |
| 3 |
* OpenStation — Content Graph: graph builder. |
| 4 |
* |
| 5 |
* Walks the WordPress post store to produce two arrays for the bundle: |
| 6 |
* |
| 7 |
* - `nodes` — one entry per post matching the requested types. |
| 8 |
* - `edges` — one entry per internal hyperlink found in any node's |
| 9 |
* `post_content`, deduped, with self-edges suppressed. |
| 10 |
* |
| 11 |
* The expensive piece is the link extraction: every published post in |
| 12 |
* scope is parsed with `DOMDocument`, every `<a href>` is scanned, and |
| 13 |
* each href is resolved with `url_to_postid()`. We cache the full |
| 14 |
* `{ nodes, edges }` tuple in a transient keyed on the requested |
| 15 |
* `types`, the viewer's private-post privilege tier, plus a hash of |
| 16 |
* the relevant rows' `post_modified_gmt`. Any |
| 17 |
* change to a participating post invalidates the hash, so save_post |
| 18 |
* implicitly refreshes the graph the next time it's requested. We |
| 19 |
* also explicitly bust the cache on `save_post` and `deleted_post` so |
| 20 |
* editors don't see stale data even if some other process pre-warms |
| 21 |
* the transient. |
| 22 |
* |
| 23 |
* @package OpenStation |
| 24 |
*/ |
| 25 |
|
| 26 |
defined( 'ABSPATH' ) || exit; |
| 27 |
|
| 28 |
// Bump the suffix whenever the cached payload shape changes — older |
| 29 |
// transients still alive at upgrade time would otherwise return the |
| 30 |
// previous schema (e.g. missing per-node `contributor_ids`) and |
| 31 |
// surface as runtime errors on the client. Each bump is a one-time |
| 32 |
// cache miss for every site that updates the plugin. |
| 33 |
/** |
| 34 |
* The VALUE keeps its pre-rebrand spelling on purpose: it is a |
| 35 |
* persisted or externally-visible identifier, so renaming it would |
| 36 |
* orphan data already written by live installs (or break a live |
| 37 |
* URL). The mismatch between this constant's name and its value is |
| 38 |
* deliberate — it is NOT a half-finished rename. |
| 39 |
*/ |
| 40 |
const OPENSTATION_CONTENT_GRAPH_TRANSIENT_PREFIX = 'desktop_mode_cg3_'; |
| 41 |
const OPENSTATION_CONTENT_GRAPH_TRANSIENT_TTL = 6 * HOUR_IN_SECONDS; |
| 42 |
|
| 43 |
/** |
| 44 |
* Build (or reuse the cached version of) the graph payload for the |
| 45 |
* requested post types. |
| 46 |
* |
| 47 |
* @param string[] $types Post type slugs. Filtered against the public |
| 48 |
* post-type registry, attachments excluded. |
| 49 |
* @return array{ |
| 50 |
* nodes: array<int, array{ |
| 51 |
* id: int, type: string, title: string, status: string, |
| 52 |
* slug: string, edit_url: string, |
| 53 |
* author_id: int, contributor_ids: int[], |
| 54 |
* year: int, year_month: string, |
| 55 |
* category_ids: int[], tag_ids: int[] |
| 56 |
* }>, |
| 57 |
* edges: array<int, array{ from: int, to: int }>, |
| 58 |
* groups: array{ |
| 59 |
* authors: array<int, array{ name: string }>, |
| 60 |
* categories: array<int, array{ name: string }>, |
| 61 |
* tags: array<int, array{ name: string }> |
| 62 |
* }, |
| 63 |
* stats: array{ nodes: int, edges: int, generated_at: int } |
| 64 |
* } |
| 65 |
*/ |
| 66 |
function openstation_content_graph_build( array $types ) { |
| 67 |
$types = openstation_content_graph_normalize_types( $types ); |
| 68 |
if ( empty( $types ) ) { |
| 69 |
return array( |
| 70 |
'nodes' => array(), |
| 71 |
'edges' => array(), |
| 72 |
'groups' => array( |
| 73 |
'authors' => array(), |
| 74 |
'categories' => array(), |
| 75 |
'tags' => array(), |
| 76 |
), |
| 77 |
'stats' => array( |
| 78 |
'nodes' => 0, |
| 79 |
'edges' => 0, |
| 80 |
'generated_at' => time(), |
| 81 |
), |
| 82 |
); |
| 83 |
} |
| 84 |
|
| 85 |
$cache_key = openstation_content_graph_cache_key( $types ); |
| 86 |
$cached = get_transient( $cache_key ); |
| 87 |
if ( is_array( $cached ) && isset( $cached['nodes'], $cached['edges'] ) ) { |
| 88 |
return $cached; |
| 89 |
} |
| 90 |
|
| 91 |
$rows = openstation_content_graph_fetch_rows( $types ); |
| 92 |
|
| 93 |
$post_ids = array(); |
| 94 |
foreach ( $rows as $row ) { |
| 95 |
$post_ids[] = (int) $row->ID; |
| 96 |
} |
| 97 |
|
| 98 |
$terms_by_post = openstation_content_graph_collect_post_terms( $post_ids ); |
| 99 |
// Distinct revision authors per post — used to pull collaborator |
| 100 |
// posts toward both the primary author's cluster AND the |
| 101 |
// contributor's clusters when grouping by author. Bulk-queried so |
| 102 |
// we don't N+1 `wp_get_post_revisions` per node. |
| 103 |
$contribs_by_post = openstation_content_graph_collect_post_contributors( $post_ids ); |
| 104 |
|
| 105 |
$default_category = max( 1, (int) get_option( 'default_category', 1 ) ); |
| 106 |
|
| 107 |
$nodes = array(); |
| 108 |
$nodes_by_id = array(); |
| 109 |
$author_ids = array(); |
| 110 |
$cat_ids = array(); |
| 111 |
$tag_ids = array(); |
| 112 |
foreach ( $rows as $row ) { |
| 113 |
$id = (int) $row->ID; |
| 114 |
$author_id = (int) $row->post_author; |
| 115 |
$year = 0; |
| 116 |
$year_month = ''; |
| 117 |
if ( ! empty( $row->post_date ) ) { |
| 118 |
// Use post_date (site-local) rather than post_date_gmt for the |
| 119 |
// "year published" / "year-month published" buckets — editors |
| 120 |
// think in their own timezone. |
| 121 |
$year = (int) mysql2date( 'Y', $row->post_date, false ); |
| 122 |
$year_month = (string) mysql2date( 'Y-m', $row->post_date, false ); |
| 123 |
} |
| 124 |
$post_cats = isset( $terms_by_post[ $id ]['category'] ) |
| 125 |
? $terms_by_post[ $id ]['category'] |
| 126 |
: array(); |
| 127 |
// A category-supporting post with zero terms is what WP treats |
| 128 |
// as "in the default category" at authoring time (core auto- |
| 129 |
// assigns it on save for `post`). Mirror that here so such |
| 130 |
// posts group under the real default-category cluster instead |
| 131 |
// of the client's synthetic "Uncategorized" pseudo-cluster |
| 132 |
// (`cat:uncat`, kept client-side only as a stale-payload |
| 133 |
// fallback). |
| 134 |
if ( empty( $post_cats ) && is_object_in_taxonomy( $row->post_type, 'category' ) ) { |
| 135 |
$post_cats = array( $default_category ); |
| 136 |
} |
| 137 |
$post_tags = isset( $terms_by_post[ $id ]['post_tag'] ) |
| 138 |
? $terms_by_post[ $id ]['post_tag'] |
| 139 |
: array(); |
| 140 |
$contribs = isset( $contribs_by_post[ $id ] ) |
| 141 |
? $contribs_by_post[ $id ] |
| 142 |
: array(); |
| 143 |
// Strip the primary author from the contributor list — the |
| 144 |
// node carries that separately in `author_id`, and surfacing |
| 145 |
// it twice on the client would push the post toward its own |
| 146 |
// primary cluster with no balancing contributor pull. |
| 147 |
if ( $author_id > 0 && ! empty( $contribs ) ) { |
| 148 |
$contribs = array_values( |
| 149 |
array_filter( |
| 150 |
$contribs, |
| 151 |
static function ( $cid ) use ( $author_id ) { |
| 152 |
return (int) $cid !== $author_id; |
| 153 |
} |
| 154 |
) |
| 155 |
); |
| 156 |
} |
| 157 |
|
| 158 |
$node = array( |
| 159 |
'id' => $id, |
| 160 |
'type' => (string) $row->post_type, |
| 161 |
'title' => (string) get_the_title( $row ), |
| 162 |
'status' => (string) $row->post_status, |
| 163 |
'slug' => (string) $row->post_name, |
| 164 |
'edit_url' => (string) get_edit_post_link( $id, 'raw' ), |
| 165 |
'author_id' => $author_id, |
| 166 |
'contributor_ids' => $contribs, |
| 167 |
'year' => $year, |
| 168 |
'year_month' => $year_month, |
| 169 |
'category_ids' => $post_cats, |
| 170 |
'tag_ids' => $post_tags, |
| 171 |
); |
| 172 |
$nodes[] = $node; |
| 173 |
$nodes_by_id[ $id ] = true; |
| 174 |
if ( $author_id > 0 ) { |
| 175 |
$author_ids[ $author_id ] = true; |
| 176 |
} |
| 177 |
foreach ( $contribs as $cid ) { |
| 178 |
if ( (int) $cid > 0 ) { |
| 179 |
$author_ids[ (int) $cid ] = true; |
| 180 |
} |
| 181 |
} |
| 182 |
foreach ( $post_cats as $tid ) { |
| 183 |
$cat_ids[ (int) $tid ] = true; |
| 184 |
} |
| 185 |
foreach ( $post_tags as $tid ) { |
| 186 |
$tag_ids[ (int) $tid ] = true; |
| 187 |
} |
| 188 |
} |
| 189 |
|
| 190 |
$groups = array( |
| 191 |
'authors' => openstation_content_graph_format_author_catalog( array_keys( $author_ids ) ), |
| 192 |
'categories' => openstation_content_graph_format_term_catalog( array_keys( $cat_ids ), 'category' ), |
| 193 |
'tags' => openstation_content_graph_format_term_catalog( array_keys( $tag_ids ), 'post_tag' ), |
| 194 |
); |
| 195 |
|
| 196 |
$edges_seen = array(); |
| 197 |
$edges = array(); |
| 198 |
foreach ( $rows as $row ) { |
| 199 |
$from = (int) $row->ID; |
| 200 |
$tos = openstation_content_graph_extract_internal_links( (string) $row->post_content ); |
| 201 |
foreach ( $tos as $to ) { |
| 202 |
if ( $to === $from ) { |
| 203 |
continue; |
| 204 |
} |
| 205 |
if ( empty( $nodes_by_id[ $to ] ) ) { |
| 206 |
// Linked post exists but is not in the requested types |
| 207 |
// scope, or is not a published post. Skip; the user |
| 208 |
// can widen the filter to surface it. |
| 209 |
continue; |
| 210 |
} |
| 211 |
$key = $from . '->' . $to; |
| 212 |
if ( isset( $edges_seen[ $key ] ) ) { |
| 213 |
continue; |
| 214 |
} |
| 215 |
$edges_seen[ $key ] = true; |
| 216 |
$edges[] = array( |
| 217 |
'from' => $from, |
| 218 |
'to' => $to, |
| 219 |
); |
| 220 |
} |
| 221 |
} |
| 222 |
|
| 223 |
$payload = array( |
| 224 |
'nodes' => $nodes, |
| 225 |
'edges' => $edges, |
| 226 |
'groups' => $groups, |
| 227 |
'stats' => array( |
| 228 |
'nodes' => count( $nodes ), |
| 229 |
'edges' => count( $edges ), |
| 230 |
'generated_at' => time(), |
| 231 |
), |
| 232 |
); |
| 233 |
|
| 234 |
set_transient( $cache_key, $payload, OPENSTATION_CONTENT_GRAPH_TRANSIENT_TTL ); |
| 235 |
|
| 236 |
return $payload; |
| 237 |
} |
| 238 |
|
| 239 |
/** |
| 240 |
* Filter, sanitize, and uniquify the requested type slugs against the |
| 241 |
* public post-type registry. Returned slugs are guaranteed to exist |
| 242 |
* AND to be among the slugs declared by |
| 243 |
* `openstation_content_graph_post_types()`. |
| 244 |
* |
| 245 |
* @param string[] $types |
| 246 |
* @return string[] |
| 247 |
*/ |
| 248 |
function openstation_content_graph_normalize_types( array $types ) { |
| 249 |
$allowed = array(); |
| 250 |
foreach ( openstation_content_graph_post_types() as $entry ) { |
| 251 |
if ( ! empty( $entry['slug'] ) ) { |
| 252 |
$allowed[ (string) $entry['slug'] ] = true; |
| 253 |
} |
| 254 |
} |
| 255 |
$out = array(); |
| 256 |
foreach ( $types as $slug ) { |
| 257 |
$slug = sanitize_key( (string) $slug ); |
| 258 |
if ( '' !== $slug && isset( $allowed[ $slug ] ) ) { |
| 259 |
$out[ $slug ] = true; |
| 260 |
} |
| 261 |
} |
| 262 |
return array_keys( $out ); |
| 263 |
} |
| 264 |
|
| 265 |
/** |
| 266 |
* Build the SQL WHERE fragment (plus its `prepare()` values and a |
| 267 |
* cache-key signature) scoping graph rows to posts the current user |
| 268 |
* is allowed to read. |
| 269 |
* |
| 270 |
* Published posts are always in scope. Private posts of a type are |
| 271 |
* only included when the user holds that type's `read_private_posts` |
| 272 |
* capability; for the remaining types the user still sees their OWN |
| 273 |
* private posts (mirroring core's `WP_Query` status semantics for |
| 274 |
* logged-in users). |
| 275 |
* |
| 276 |
* The `key` element encodes the resulting privilege tier (and, when |
| 277 |
* the own-author clause is active, the user id) so cached payloads |
| 278 |
* are never served across privilege levels. |
| 279 |
* |
| 280 |
* @param string[] $types Already normalized. |
| 281 |
* @return array{ where: string, values: array, key: string } |
| 282 |
*/ |
| 283 |
function openstation_content_graph_visibility_sql( array $types ) { |
| 284 |
$placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) ); |
| 285 |
$values = $types; |
| 286 |
|
| 287 |
$priv_types = array(); |
| 288 |
foreach ( $types as $type ) { |
| 289 |
$type_obj = get_post_type_object( $type ); |
| 290 |
$cap = ( $type_obj && ! empty( $type_obj->cap->read_private_posts ) ) |
| 291 |
? $type_obj->cap->read_private_posts |
| 292 |
: 'read_private_posts'; |
| 293 |
if ( current_user_can( $cap ) ) { |
| 294 |
$priv_types[] = $type; |
| 295 |
} |
| 296 |
} |
| 297 |
|
| 298 |
$status_clauses = array( "post_status = 'publish'" ); |
| 299 |
$key_parts = array( 'priv=' . implode( ',', $priv_types ) ); |
| 300 |
|
| 301 |
if ( ! empty( $priv_types ) ) { |
| 302 |
$priv_placeholders = implode( ',', array_fill( 0, count( $priv_types ), '%s' ) ); |
| 303 |
$status_clauses[] = "( post_status = 'private' AND post_type IN ( {$priv_placeholders} ) )"; |
| 304 |
$values = array_merge( $values, $priv_types ); |
| 305 |
} |
| 306 |
|
| 307 |
$user_id = get_current_user_id(); |
| 308 |
if ( $user_id > 0 && count( $priv_types ) < count( $types ) ) { |
| 309 |
$status_clauses[] = "( post_status = 'private' AND post_author = %d )"; |
| 310 |
$values[] = $user_id; |
| 311 |
$key_parts[] = 'own=' . $user_id; |
| 312 |
} |
| 313 |
|
| 314 |
$where = "post_type IN ( {$placeholders} ) AND ( " . implode( ' OR ', $status_clauses ) . ' )'; |
| 315 |
|
| 316 |
return array( |
| 317 |
'where' => $where, |
| 318 |
'values' => $values, |
| 319 |
'key' => implode( '|', $key_parts ), |
| 320 |
); |
| 321 |
} |
| 322 |
|
| 323 |
/** |
| 324 |
* Cache key for `{ nodes, edges }` for a given type set. Includes a |
| 325 |
* short hash of the participating rows' post_modified_gmt so any |
| 326 |
* relevant edit busts the cache implicitly, plus the viewer's |
| 327 |
* privilege-tier signature so a payload built for a user who can read |
| 328 |
* private posts is never served to one who can't (and vice versa). |
| 329 |
* |
| 330 |
* @param string[] $types Already normalized. |
| 331 |
* @return string |
| 332 |
*/ |
| 333 |
function openstation_content_graph_cache_key( array $types ) { |
| 334 |
global $wpdb; |
| 335 |
$visibility = openstation_content_graph_visibility_sql( $types ); |
| 336 |
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 337 |
$hash = (string) $wpdb->get_var( |
| 338 |
$wpdb->prepare( |
| 339 |
"SELECT MD5( GROUP_CONCAT( CONCAT( ID, ':', post_modified_gmt ) ORDER BY ID ) ) |
| 340 |
FROM {$wpdb->posts} |
| 341 |
WHERE {$visibility['where']}", |
| 342 |
$visibility['values'] |
| 343 |
) |
| 344 |
); |
| 345 |
// phpcs:enable |
| 346 |
if ( '' === $hash || null === $hash ) { |
| 347 |
$hash = 'empty'; |
| 348 |
} |
| 349 |
return OPENSTATION_CONTENT_GRAPH_TRANSIENT_PREFIX . substr( md5( implode( ',', $types ) . '|' . $visibility['key'] . '|' . $hash ), 0, 24 ); |
| 350 |
} |
| 351 |
|
| 352 |
/** |
| 353 |
* Fetch the participating posts in a single query. Returns full rows |
| 354 |
* (including `post_content`) so the link extractor can run without N+1 |
| 355 |
* `get_post()` calls. |
| 356 |
* |
| 357 |
* Rows are scoped to what the current user can read: published posts, |
| 358 |
* plus private posts only where the user holds the type's |
| 359 |
* `read_private_posts` capability (or authored the post). See |
| 360 |
* `openstation_content_graph_visibility_sql()`. |
| 361 |
* |
| 362 |
* @param string[] $types Already normalized. |
| 363 |
* @return WP_Post[] |
| 364 |
*/ |
| 365 |
function openstation_content_graph_fetch_rows( array $types ) { |
| 366 |
global $wpdb; |
| 367 |
$visibility = openstation_content_graph_visibility_sql( $types ); |
| 368 |
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 369 |
$rows = $wpdb->get_results( |
| 370 |
$wpdb->prepare( |
| 371 |
"SELECT ID, post_type, post_status, post_title, post_name, post_content, post_author, post_date |
| 372 |
FROM {$wpdb->posts} |
| 373 |
WHERE {$visibility['where']} |
| 374 |
ORDER BY post_date DESC", |
| 375 |
$visibility['values'] |
| 376 |
) |
| 377 |
); |
| 378 |
// phpcs:enable |
| 379 |
|
| 380 |
if ( ! is_array( $rows ) ) { |
| 381 |
return array(); |
| 382 |
} |
| 383 |
|
| 384 |
// Hydrate as WP_Post so get_the_title()/get_edit_post_link() work |
| 385 |
// without additional queries. update_post_caches() warms the |
| 386 |
// objects so subsequent helper calls hit the cache. |
| 387 |
$posts = array(); |
| 388 |
foreach ( $rows as $row ) { |
| 389 |
$post = new WP_Post( $row ); |
| 390 |
$posts[] = $post; |
| 391 |
} |
| 392 |
if ( ! empty( $posts ) ) { |
| 393 |
update_post_caches( $posts, '', false, false ); |
| 394 |
} |
| 395 |
return $posts; |
| 396 |
} |
| 397 |
|
| 398 |
/** |
| 399 |
* Pull every internal-target post id out of a chunk of post_content. |
| 400 |
* Uses DOMDocument for robustness against malformed HTML, then |
| 401 |
* `url_to_postid()` to resolve each href. |
| 402 |
* |
| 403 |
* @param string $content |
| 404 |
* @return int[] Unique target post ids (order preserved). |
| 405 |
*/ |
| 406 |
function openstation_content_graph_extract_internal_links( $content ) { |
| 407 |
if ( '' === trim( (string) $content ) ) { |
| 408 |
return array(); |
| 409 |
} |
| 410 |
|
| 411 |
// `<base>` shenanigans aside, url_to_postid() handles relative, |
| 412 |
// absolute, query-string, pretty, and ?p= forms uniformly. |
| 413 |
$ids = array(); |
| 414 |
$seen = array(); |
| 415 |
$prev = libxml_use_internal_errors( true ); |
| 416 |
$dom = new DOMDocument(); |
| 417 |
// `loadHTML` insists on a charset hint to avoid mangling utf-8. |
| 418 |
$loaded = $dom->loadHTML( '<?xml encoding="utf-8"?>' . $content ); |
| 419 |
libxml_clear_errors(); |
| 420 |
libxml_use_internal_errors( $prev ); |
| 421 |
if ( ! $loaded ) { |
| 422 |
return array(); |
| 423 |
} |
| 424 |
|
| 425 |
$anchors = $dom->getElementsByTagName( 'a' ); |
| 426 |
foreach ( $anchors as $anchor ) { |
| 427 |
/** @var DOMElement $anchor */ |
| 428 |
$href = trim( (string) $anchor->getAttribute( 'href' ) ); |
| 429 |
if ( '' === $href ) { |
| 430 |
continue; |
| 431 |
} |
| 432 |
// Skip obvious non-internal targets fast. |
| 433 |
if ( 0 === strpos( $href, '#' ) ) { |
| 434 |
continue; |
| 435 |
} |
| 436 |
if ( preg_match( '#^(mailto:|tel:|javascript:|data:)#i', $href ) ) { |
| 437 |
continue; |
| 438 |
} |
| 439 |
$post_id = (int) url_to_postid( $href ); |
| 440 |
if ( $post_id <= 0 || isset( $seen[ $post_id ] ) ) { |
| 441 |
continue; |
| 442 |
} |
| 443 |
$seen[ $post_id ] = true; |
| 444 |
$ids[] = $post_id; |
| 445 |
} |
| 446 |
|
| 447 |
return $ids; |
| 448 |
} |
| 449 |
|
| 450 |
/** |
| 451 |
* Cache invalidation. Any post-type change wipes every transient |
| 452 |
* carrying the `openstation_cg_` prefix. We don't have a per-type |
| 453 |
* index so we wipe globally, the cost is one extra build on next |
| 454 |
* open which dominates the time-savings on subsequent opens. |
| 455 |
*/ |
| 456 |
function openstation_content_graph_flush_cache() { |
| 457 |
global $wpdb; |
| 458 |
// Enumerate matching transient option names first, then route each |
| 459 |
// through `delete_transient()` so WP's transient/options cache |
| 460 |
// invalidation runs alongside the wp_options delete. The earlier |
| 461 |
// raw `$wpdb->query("DELETE FROM ...")` only cleared the table — |
| 462 |
// any subsequent `get_transient()` in the same process still hit |
| 463 |
// the in-memory options cache and returned the stale payload. |
| 464 |
// This bit the `set_object_terms` invalidation path because that |
| 465 |
// hook doesn't update `post_modified_gmt`, so the cache key stayed |
| 466 |
// identical and `get_transient` (cache hit) returned pre-retag data. |
| 467 |
$prefix_like = $wpdb->esc_like( '_transient_' . OPENSTATION_CONTENT_GRAPH_TRANSIENT_PREFIX ) . '%'; |
| 468 |
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 469 |
$option_names = $wpdb->get_col( |
| 470 |
$wpdb->prepare( |
| 471 |
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", |
| 472 |
$prefix_like |
| 473 |
) |
| 474 |
); |
| 475 |
// phpcs:enable |
| 476 |
if ( ! is_array( $option_names ) || empty( $option_names ) ) { |
| 477 |
return; |
| 478 |
} |
| 479 |
$prefix_len = strlen( '_transient_' ); |
| 480 |
foreach ( $option_names as $option_name ) { |
| 481 |
$transient = substr( (string) $option_name, $prefix_len ); |
| 482 |
if ( '' !== $transient ) { |
| 483 |
delete_transient( $transient ); |
| 484 |
} |
| 485 |
} |
| 486 |
} |
| 487 |
add_action( 'save_post', 'openstation_content_graph_flush_cache' ); |
| 488 |
add_action( 'deleted_post', 'openstation_content_graph_flush_cache' ); |
| 489 |
// Term assignments can change outside the post-edit path (CLI, bulk |
| 490 |
// quick-edit, REST). Without this the per-node `category_ids` / `tag_ids` |
| 491 |
// the group-by UI reads would go stale until the 6h TTL expires. |
| 492 |
add_action( 'set_object_terms', 'openstation_content_graph_flush_cache' ); |
| 493 |
|
| 494 |
/** |
| 495 |
* Bulk-fetch every (post_id → taxonomy → term_ids[]) mapping for the |
| 496 |
* `category` and `post_tag` taxonomies in a single query. Used to |
| 497 |
* populate the per-node `category_ids` / `tag_ids` arrays without N+1 |
| 498 |
* `wp_get_object_terms` calls. |
| 499 |
* |
| 500 |
* @param int[] $post_ids |
| 501 |
* @return array<int, array<string, int[]>> Outer key = post id; inner |
| 502 |
* key = taxonomy slug; value = term ids the post is in. |
| 503 |
*/ |
| 504 |
function openstation_content_graph_collect_post_terms( array $post_ids ) { |
| 505 |
$post_ids = array_values( array_filter( array_map( 'intval', $post_ids ) ) ); |
| 506 |
if ( empty( $post_ids ) ) { |
| 507 |
return array(); |
| 508 |
} |
| 509 |
global $wpdb; |
| 510 |
$placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ); |
| 511 |
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 512 |
$rows = $wpdb->get_results( |
| 513 |
$wpdb->prepare( |
| 514 |
"SELECT tr.object_id, tt.term_id, tt.taxonomy |
| 515 |
FROM {$wpdb->term_relationships} tr |
| 516 |
INNER JOIN {$wpdb->term_taxonomy} tt |
| 517 |
ON tr.term_taxonomy_id = tt.term_taxonomy_id |
| 518 |
WHERE tr.object_id IN ( {$placeholders} ) |
| 519 |
AND tt.taxonomy IN ( 'category', 'post_tag' )", |
| 520 |
$post_ids |
| 521 |
) |
| 522 |
); |
| 523 |
// phpcs:enable |
| 524 |
$out = array(); |
| 525 |
if ( ! is_array( $rows ) ) { |
| 526 |
return $out; |
| 527 |
} |
| 528 |
foreach ( $rows as $row ) { |
| 529 |
$pid = (int) $row->object_id; |
| 530 |
$tax = (string) $row->taxonomy; |
| 531 |
$tid = (int) $row->term_id; |
| 532 |
if ( ! isset( $out[ $pid ] ) ) { |
| 533 |
$out[ $pid ] = array(); |
| 534 |
} |
| 535 |
if ( ! isset( $out[ $pid ][ $tax ] ) ) { |
| 536 |
$out[ $pid ][ $tax ] = array(); |
| 537 |
} |
| 538 |
$out[ $pid ][ $tax ][] = $tid; |
| 539 |
} |
| 540 |
return $out; |
| 541 |
} |
| 542 |
|
| 543 |
/** |
| 544 |
* Bulk-fetch distinct revision authors per post for the requested |
| 545 |
* post ids. Used by `openstation_content_graph_build()` to populate |
| 546 |
* each node's `contributor_ids` array so the cluster-attractor force |
| 547 |
* can pull collaborator posts toward both the primary author's |
| 548 |
* cluster AND each contributor's cluster (weighted so the primary |
| 549 |
* still wins). |
| 550 |
* |
| 551 |
* Returns the distinct `post_author` values from each in-scope post's |
| 552 |
* revision children. Includes the primary author if they also |
| 553 |
* authored a revision; the caller is expected to filter that out. |
| 554 |
* |
| 555 |
* @param int[] $post_ids |
| 556 |
* @return array<int, int[]> post_id => list of contributor user ids. |
| 557 |
*/ |
| 558 |
function openstation_content_graph_collect_post_contributors( array $post_ids ) { |
| 559 |
$post_ids = array_values( array_filter( array_map( 'intval', $post_ids ) ) ); |
| 560 |
if ( empty( $post_ids ) ) { |
| 561 |
return array(); |
| 562 |
} |
| 563 |
global $wpdb; |
| 564 |
$placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ); |
| 565 |
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 566 |
$rows = $wpdb->get_results( |
| 567 |
$wpdb->prepare( |
| 568 |
"SELECT post_parent AS post_id, post_author |
| 569 |
FROM {$wpdb->posts} |
| 570 |
WHERE post_type = 'revision' |
| 571 |
AND post_parent IN ( {$placeholders} ) |
| 572 |
AND post_author > 0 |
| 573 |
GROUP BY post_parent, post_author", |
| 574 |
$post_ids |
| 575 |
) |
| 576 |
); |
| 577 |
// phpcs:enable |
| 578 |
$out = array(); |
| 579 |
if ( ! is_array( $rows ) ) { |
| 580 |
return $out; |
| 581 |
} |
| 582 |
foreach ( $rows as $row ) { |
| 583 |
$pid = (int) $row->post_id; |
| 584 |
$uid = (int) $row->post_author; |
| 585 |
if ( ! isset( $out[ $pid ] ) ) { |
| 586 |
$out[ $pid ] = array(); |
| 587 |
} |
| 588 |
$out[ $pid ][] = $uid; |
| 589 |
} |
| 590 |
return $out; |
| 591 |
} |
| 592 |
|
| 593 |
/** |
| 594 |
* Build a `{ id => { name } }` catalog for the given author ids. |
| 595 |
* Uses one `WP_User_Query` rather than per-id `get_userdata` calls. |
| 596 |
* |
| 597 |
* @param int[] $author_ids |
| 598 |
* @return array<int, array{ name: string }> |
| 599 |
*/ |
| 600 |
function openstation_content_graph_format_author_catalog( array $author_ids ) { |
| 601 |
$author_ids = array_values( array_unique( array_filter( array_map( 'intval', $author_ids ) ) ) ); |
| 602 |
if ( empty( $author_ids ) ) { |
| 603 |
return array(); |
| 604 |
} |
| 605 |
$query = new WP_User_Query( |
| 606 |
array( |
| 607 |
'include' => $author_ids, |
| 608 |
'fields' => array( 'ID', 'display_name' ), |
| 609 |
'number' => count( $author_ids ), |
| 610 |
) |
| 611 |
); |
| 612 |
$out = array(); |
| 613 |
foreach ( (array) $query->get_results() as $user ) { |
| 614 |
$out[ (int) $user->ID ] = array( |
| 615 |
'name' => (string) $user->display_name, |
| 616 |
); |
| 617 |
} |
| 618 |
return $out; |
| 619 |
} |
| 620 |
|
| 621 |
/** |
| 622 |
* Build a `{ id => { name } }` catalog for the given term ids in a |
| 623 |
* single taxonomy. Uses `get_terms` with `include` so the names come |
| 624 |
* back in one query. |
| 625 |
* |
| 626 |
* @param int[] $term_ids |
| 627 |
* @param string $taxonomy |
| 628 |
* @return array<int, array{ name: string }> |
| 629 |
*/ |
| 630 |
function openstation_content_graph_format_term_catalog( array $term_ids, $taxonomy ) { |
| 631 |
$term_ids = array_values( array_unique( array_filter( array_map( 'intval', $term_ids ) ) ) ); |
| 632 |
if ( empty( $term_ids ) ) { |
| 633 |
return array(); |
| 634 |
} |
| 635 |
$terms = get_terms( |
| 636 |
array( |
| 637 |
'taxonomy' => (string) $taxonomy, |
| 638 |
'include' => $term_ids, |
| 639 |
'hide_empty' => false, |
| 640 |
'number' => count( $term_ids ), |
| 641 |
) |
| 642 |
); |
| 643 |
$out = array(); |
| 644 |
if ( is_wp_error( $terms ) || ! is_array( $terms ) ) { |
| 645 |
return $out; |
| 646 |
} |
| 647 |
foreach ( $terms as $term ) { |
| 648 |
$out[ (int) $term->term_id ] = array( |
| 649 |
'name' => (string) $term->name, |
| 650 |
); |
| 651 |
} |
| 652 |
return $out; |
| 653 |
} |
| 654 |
|