PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.8
1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 All 33 releases
desktop-mode / includes / content-graph / graph-builder.php

graph-builder.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.8, at includes/content-graph/graph-builder.php

654 lines 21.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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