PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.3
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.3
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.3, at includes/content-graph/graph-builder.php

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