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

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