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

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