PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
1.1.10 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 All 34 releases
desktop-mode / includes / content-graph / graph-builder.php

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

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