PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 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 All 37 releases
desktop-mode / apps / my-wordpress / parts / dossiers.php

dossiers.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.13, at apps/my-wordpress/parts/dossiers.php

503 lines 16.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * My WordPress — navigate-into: the detail folder and its sub-lists.
4 *
5 * Part of the `my-wordpress` app: required by `my-wordpress.os.php`,
6 * same namespace, plain `.php` on purpose — only `*.os.php` files are
7 * app entries to the framework loader. This part owns what a post
8 * OPENS INTO: the relation folders with live counts, each relation's
9 * rows, the right-pane dossier behind a selected row (rendered
10 * through WP Explorer's own stats callbacks), the Edit… modal's
11 * choice lists, and the shared preview-action descriptors.
12 *
13 * @package OpenStation
14 */
15
16 namespace OpenStation\Apps\MyWordPress;
17
18 use OpenStation\App\Os;
19
20 // Direct access, unless a standalone host is booting on bare PHP.
21 if ( ! defined( 'ABSPATH' ) ) {
22 defined( 'OPENSTATION_STANDALONE' ) || exit;
23 }
24
25 /**
26 * The ids "Attached media" lists and counts: the post's attachments
27 * plus its featured image, each once.
28 *
29 * @param \WP_Post $post Post.
30 * @return int[]
31 */
32 function media_ids( \WP_Post $post ) {
33 $ids = get_children(
34 array(
35 'post_parent' => $post->ID,
36 'post_type' => 'attachment',
37 'fields' => 'ids',
38 )
39 );
40 $ids = array_map( 'intval', $ids );
41 if ( has_post_thumbnail( $post ) ) {
42 $ids[] = (int) get_post_thumbnail_id( $post );
43 }
44 return array_unique( $ids );
45 }
46
47 /**
48 * The detail FOLDER a post navigates into: the rendered article plus
49 * one folder tile per related surface — Author, Contributors,
50 * Comments, Categories, Tags, Attached media, Revisions — with live
51 * counts. WP Explorer's detail view, as data.
52 *
53 * @param Os $os Host handle.
54 * @param array<string,mixed> $section Section descriptor.
55 * @param int $id Post id.
56 * @return array<string,mixed>|null Null when the post vanished.
57 */
58 function folder( Os $os, array $section, $id ) {
59 $post = get_post( $id );
60 if ( ! $post || $post->post_type !== $section['post_type'] ) {
61 return null;
62 }
63
64 $contributors = function_exists( 'openstation_my_wordpress_post_contributors_payload' )
65 ? openstation_my_wordpress_post_contributors_payload( $id )
66 : array();
67 $categories = get_the_terms( $post, 'category' );
68 $categories = is_array( $categories ) ? $categories : array();
69 $tags = get_the_terms( $post, 'post_tag' );
70 $tags = is_array( $tags ) ? $tags : array();
71 $media_count = count( media_ids( $post ) );
72 $comments = (int) get_comments_number( $post );
73 $revisions = wp_revisions_enabled( $post ) ? count( wp_get_post_revisions( $id, array( 'fields' => 'ids' ) ) ) : 0;
74
75 $folders = array();
76 $folders[] = array(
77 'relation' => 'author',
78 'label' => __( 'Author', 'desktop-mode' ),
79 'icon' => 'dashicons-admin-users',
80 'count' => 1,
81 );
82 if ( array() !== $contributors ) {
83 $folders[] = array(
84 'relation' => 'contributors',
85 'label' => __( 'Contributors', 'desktop-mode' ),
86 'icon' => 'dashicons-groups',
87 'count' => count( $contributors ),
88 );
89 }
90 $folders[] = array(
91 'relation' => 'comments',
92 'label' => __( 'Comments', 'desktop-mode' ),
93 'icon' => 'dashicons-admin-comments',
94 'count' => $comments,
95 'disabled' => 0 === $comments && 'closed' === $post->comment_status,
96 );
97 if ( array() !== $categories ) {
98 $folders[] = array(
99 'relation' => 'categories',
100 'label' => __( 'Categories', 'desktop-mode' ),
101 'icon' => 'dashicons-category',
102 'count' => count( $categories ),
103 );
104 }
105 if ( array() !== $tags ) {
106 $folders[] = array(
107 'relation' => 'tags',
108 'label' => __( 'Tags', 'desktop-mode' ),
109 'icon' => 'dashicons-tag',
110 'count' => count( $tags ),
111 );
112 }
113 $folders[] = array(
114 'relation' => 'media',
115 'label' => __( 'Attached media', 'desktop-mode' ),
116 'icon' => 'dashicons-format-image',
117 'count' => $media_count,
118 );
119 $folders[] = array(
120 'relation' => 'revisions',
121 'label' => __( 'Revisions', 'desktop-mode' ),
122 'icon' => 'dashicons-backup',
123 'count' => $revisions,
124 );
125
126 return array(
127 'id' => $id,
128 'title' => '' !== $post->post_title ? openstation_plain_text_title( $post->post_title ) : __( '(no title)', 'desktop-mode' ),
129 'status' => (string) $post->post_status,
130 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying Core's own content pipeline, not declaring a hook.
131 'content' => (string) apply_filters( 'the_content', (string) $post->post_content ),
132 'folders' => $folders,
133 );
134 }
135
136 /**
137 * The rows inside one relation sub-folder. Uniform shape: `id`,
138 * `title`, `subtitle`, `icon` | `thumb`, `editUrl`.
139 *
140 * @param Os $os Host handle.
141 * @param array<string,mixed> $section Section descriptor.
142 * @param int $id Post id.
143 * @param string $relation Relation slug.
144 * @return array{label:string,rows:array[]}|null
145 */
146 function sub( Os $os, array $section, $id, $relation ) {
147 $post = get_post( $id );
148 if ( ! $post || $post->post_type !== $section['post_type'] ) {
149 return null;
150 }
151 $rows = array();
152 $label = '';
153
154 $user_row = static function ( $user_id, $name = '', $avatar = '' ) {
155 $user = get_userdata( (int) $user_id );
156 return array(
157 'id' => (int) $user_id,
158 'title' => openstation_plain_text_title( '' !== $name ? $name : ( $user ? (string) $user->display_name : sprintf( '#%d', $user_id ) ) ),
159 'subtitle' => $user ? (string) $user->user_email : '',
160 'thumb' => '' !== $avatar ? $avatar : (string) get_avatar_url( (int) $user_id, array( 'size' => 96 ) ),
161 'editUrl' => current_user_can( 'edit_user', (int) $user_id )
162 ? admin_url( 'user-edit.php?user_id=' . (int) $user_id )
163 : '',
164 );
165 };
166
167 switch ( $relation ) {
168 case 'author':
169 $label = __( 'Author', 'desktop-mode' );
170 $rows[] = $user_row( (int) $post->post_author );
171 break;
172 case 'contributors':
173 $label = __( 'Contributors', 'desktop-mode' );
174 if ( function_exists( 'openstation_my_wordpress_post_contributors_payload' ) ) {
175 foreach ( openstation_my_wordpress_post_contributors_payload( $id ) as $person ) {
176 $rows[] = $user_row( $person['userId'], $person['userName'], $person['userAvatarUrl'] );
177 }
178 }
179 break;
180 case 'comments':
181 $label = __( 'Comments', 'desktop-mode' );
182 foreach ( get_comments(
183 array(
184 'post_id' => $id,
185 'number' => 100,
186 )
187 ) as $comment ) {
188 $rows[] = array(
189 'id' => (int) $comment->comment_ID,
190 'title' => openstation_plain_text_title( $comment->comment_author ),
191 'subtitle' => openstation_plain_text_title( wp_trim_words( openstation_strip_all_tags( (string) $comment->comment_content ), 12 ) ),
192 'icon' => 'dashicons-admin-comments',
193 'editUrl' => current_user_can( 'edit_comment', (int) $comment->comment_ID )
194 ? admin_url( 'comment.php?action=editcomment&c=' . (int) $comment->comment_ID )
195 : '',
196 );
197 }
198 break;
199 case 'categories':
200 case 'tags':
201 $taxonomy = 'tags' === $relation ? 'post_tag' : 'category';
202 $label = 'tags' === $relation ? __( 'Tags', 'desktop-mode' ) : __( 'Categories', 'desktop-mode' );
203 $terms = get_the_terms( $post, $taxonomy );
204 foreach ( is_array( $terms ) ? $terms : array() as $term ) {
205 $rows[] = array(
206 'id' => (int) $term->term_id,
207 'title' => openstation_plain_text_title( $term->name ),
208 'subtitle' => sprintf(
209 /* translators: %s: entry count. */
210 _n( '%s entry', '%s entries', (int) $term->count, 'desktop-mode' ),
211 number_format_i18n( (int) $term->count )
212 ),
213 'icon' => 'tags' === $relation ? 'dashicons-tag' : 'dashicons-category',
214 'editUrl' => current_user_can( 'manage_categories' )
215 ? admin_url( 'term.php?taxonomy=' . $taxonomy . '&tag_ID=' . (int) $term->term_id )
216 : '',
217 );
218 }
219 break;
220 case 'media':
221 $label = __( 'Attached media', 'desktop-mode' );
222 foreach ( media_ids( $post ) as $media_id ) {
223 $media = get_post( $media_id );
224 if ( ! $media ) {
225 continue;
226 }
227 $rows[] = array(
228 'id' => $media_id,
229 'title' => '' !== $media->post_title ? openstation_plain_text_title( $media->post_title ) : sprintf( '#%d', $media_id ),
230 'subtitle' => (string) $media->post_mime_type,
231 'thumb' => (string) wp_get_attachment_image_url( $media_id, 'medium' ),
232 'icon' => 'dashicons-format-image',
233 'editUrl' => current_user_can( 'edit_post', $media_id )
234 ? admin_url( 'post.php?post=' . $media_id . '&action=edit' )
235 : '',
236 );
237 }
238 break;
239 case 'revisions':
240 $label = __( 'Revisions', 'desktop-mode' );
241 foreach ( wp_get_post_revisions( $id ) as $revision ) {
242 // A grid of identical clock icons, each labelled with
243 // Core's whole "author, 2 days ago (October 6, 2026 @
244 // 09:22:49)" sentence, told revisions apart by nothing
245 // a glance could catch. The author's face is the
246 // picture, the label is just "2 days ago", and the
247 // author and exact date ride in the tooltip.
248 $facts = revision_facts( $revision );
249 $rows[] = array(
250 'id' => (int) $revision->ID,
251 'title' => $facts['autosave']
252 /* translators: %s: how long ago, e.g. "2 days ago". */
253 ? sprintf( __( '%s · Autosave', 'desktop-mode' ), $facts['ago'] )
254 : $facts['ago'],
255 'subtitle' => $facts['author'] . ' · ' . $facts['date'],
256 'thumb' => $facts['avatar'],
257 'icon' => 'dashicons-backup',
258 'editUrl' => current_user_can( 'edit_post', $id )
259 ? admin_url( 'revision.php?revision=' . (int) $revision->ID )
260 : '',
261 );
262 }
263 break;
264 default:
265 return null;
266 }
267
268 return array(
269 'label' => $label,
270 'rows' => $rows,
271 );
272 }
273
274 /**
275 * The facts a revision is told apart by: who saved it, how long ago,
276 * the exact moment, whether it was an autosave, and the author's face.
277 *
278 * A revision whose author was deleted keeps a dangling `post_author`;
279 * it reads "Unknown author" rather than Core's leading ", 3 weeks ago".
280 *
281 * @param \WP_Post $revision Revision post.
282 * @return array{author: string, ago: string, date: string, autosave: bool, avatar: string}
283 */
284 function revision_facts( $revision ) {
285 $author_id = (int) $revision->post_author;
286 $author = $author_id ? (string) get_the_author_meta( 'display_name', $author_id ) : '';
287 $saved_gmt = (int) get_post_modified_time( 'U', true, $revision );
288
289 return array(
290 'author' => '' !== $author ? openstation_plain_text_title( $author ) : __( 'Unknown author', 'desktop-mode' ),
291 /* translators: %s: human-readable time difference, e.g. "2 days". */
292 'ago' => sprintf( __( '%s ago', 'desktop-mode' ), human_time_diff( $saved_gmt ) ),
293 'date' => (string) wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $saved_gmt ),
294 'autosave' => (bool) wp_is_post_autosave( $revision ),
295 'avatar' => '' !== $author ? (string) get_avatar_url( $author_id, array( 'size' => 128 ) ) : '',
296 );
297 }
298
299 /**
300 * Invoke one of WP Explorer's stats REST callbacks in-process, with a
301 * synthetic request — the panes render the SAME payloads WP Explorer
302 * renders, filters (`openstation_my_wordpress_term_stats` and
303 * friends) included.
304 *
305 * @param string $callback Function name.
306 * @param array<string,mixed> $params Request params.
307 * @return array<string,mixed>|null Null when unavailable or refused.
308 */
309 function stats_payload( $callback, array $params ) {
310 if ( ! function_exists( $callback ) || ! class_exists( '\WP_REST_Request' ) ) {
311 return null;
312 }
313 $request = new \WP_REST_Request();
314 foreach ( $params as $key => $value ) {
315 $request->set_param( $key, $value );
316 }
317 $payload = call_user_func( $callback, $request );
318 return is_array( $payload ) ? $payload : null;
319 }
320
321 /**
322 * The right-pane dossier for one SELECTED sub-list row, per relation:
323 * the term-stats card for a category or tag (stat tiles, 12-month
324 * activity, first/last post, recent posts), the user dossier + stats
325 * for author/contributors, the comment dossier, the media dossier
326 * with its usage scan, a revision preview.
327 *
328 * @param Os $os Host handle.
329 * @param array<string,mixed> $section Section descriptor.
330 * @param int $post_id Post navigated into.
331 * @param string $relation Relation slug.
332 * @param int $row_id Selected row id.
333 * @return array<string,mixed>|null
334 */
335 function sub_detail( Os $os, array $section, $post_id, $relation, $row_id ) {
336 switch ( $relation ) {
337 case 'categories':
338 case 'tags':
339 $stats = stats_payload(
340 'openstation_my_wordpress_term_stats_callback',
341 array(
342 'taxonomy' => 'tags' === $relation ? 'post_tag' : 'category',
343 'id' => $row_id,
344 )
345 );
346 return $stats ? array(
347 'kind' => 'term',
348 'stats' => $stats,
349 ) : null;
350
351 case 'author':
352 case 'contributors':
353 $user_section = array(
354 'kind' => 'user',
355 'post_type' => '',
356 );
357 $dossier = detail( $os, $user_section, $row_id );
358 if ( ! $dossier ) {
359 return null;
360 }
361 return array(
362 'kind' => 'user',
363 'detail' => $dossier,
364 'stats' => stats_payload( 'openstation_my_wordpress_user_stats_callback', array( 'id' => $row_id ) ),
365 );
366
367 case 'comments':
368 $stats = stats_payload( 'openstation_my_wordpress_comment_stats_callback', array( 'id' => $row_id ) );
369 return $stats ? array(
370 'kind' => 'comment',
371 'stats' => $stats,
372 ) : null;
373
374 case 'media':
375 $media_section = array(
376 'kind' => 'media',
377 'post_type' => 'attachment',
378 'thumbnails' => true,
379 );
380 $dossier = detail( $os, $media_section, $row_id );
381 return $dossier ? array(
382 'kind' => 'media',
383 'detail' => $dossier,
384 ) : null;
385
386 case 'revisions':
387 $revision = wp_get_post_revision( $row_id );
388 if ( ! $revision || (int) $revision->post_parent !== (int) $post_id || ! current_user_can( 'edit_post', $post_id ) ) {
389 return null;
390 }
391 $facts = revision_facts( $revision );
392 return array(
393 'kind' => 'revision',
394 // Plain text, built from the same facts as the tile: Core's
395 // expanded title leads with an avatar <img> and, for a
396 // deleted author, with a stray ", ".
397 'title' => sprintf(
398 /* translators: 1: author name, 2: how long ago, 3: exact date. */
399 __( '%1$s, %2$s (%3$s)', 'desktop-mode' ),
400 $facts['author'],
401 $facts['ago'],
402 $facts['date']
403 ),
404 'author' => $facts['author'],
405 'date' => (string) get_the_date( '', $revision ) . ' ' . get_the_time( '', $revision ),
406 'content' => wp_kses_post( (string) apply_filters( 'the_content', (string) $revision->post_content ) ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's own content pipeline.
407 );
408 }
409 return null;
410 }
411
412 /**
413 * The choices the Edit… modal offers: the site's authors, the
414 * category tree (in `<os-category-picker>`'s item shape, `parent`
415 * included) and the tag terms the token field suggests from. Only
416 * computed while a post-kind section is open.
417 *
418 * @return array{authors:array[],categories:array[],tags:array[]}
419 */
420 function edit_choices() {
421 $authors = array();
422 foreach ( get_users(
423 array(
424 'capability' => array( 'edit_posts' ),
425 'number' => 100,
426 'orderby' => 'display_name',
427 'fields' => array( 'ID', 'display_name' ),
428 )
429 ) as $user ) {
430 $authors[] = array(
431 'id' => (int) $user->ID,
432 'name' => openstation_plain_text_title( $user->display_name ),
433 );
434 }
435 $categories = array();
436 foreach ( get_terms(
437 array(
438 'taxonomy' => 'category',
439 'hide_empty' => false,
440 'number' => 100,
441 )
442 ) as $term ) {
443 if ( $term instanceof \WP_Term ) {
444 $categories[] = array(
445 'id' => (int) $term->term_id,
446 'name' => openstation_plain_text_title( $term->name ),
447 'parent' => (int) $term->parent,
448 );
449 }
450 }
451 $tags = array();
452 foreach ( get_terms(
453 array(
454 'taxonomy' => 'post_tag',
455 'hide_empty' => false,
456 'number' => 100,
457 )
458 ) as $term ) {
459 if ( $term instanceof \WP_Term ) {
460 $tags[] = array(
461 'id' => (int) $term->term_id,
462 'name' => openstation_plain_text_title( $term->name ),
463 );
464 }
465 }
466 return array(
467 'authors' => $authors,
468 'categories' => $categories,
469 'tags' => $tags,
470 );
471 }
472
473 /**
474 * The preview-action descriptors the acting user may see — the same
475 * `openstation_my_wordpress_preview_actions` pipeline WP Explorer
476 * collects, minus the fields the client does not need.
477 *
478 * @param Os $os Host handle.
479 * @return array<int,array<string,mixed>>
480 */
481 function preview_actions( Os $os ) {
482 if ( ! function_exists( 'openstation_my_wordpress_collect_preview_actions' ) ) {
483 return array();
484 }
485 $out = array();
486 foreach ( (array) openstation_my_wordpress_collect_preview_actions() as $action ) {
487 if ( ! is_array( $action ) || empty( $action['id'] ) ) {
488 continue;
489 }
490 if ( ! empty( $action['capability'] ) && ! $os->can( (string) $action['capability'] ) ) {
491 continue;
492 }
493 $out[] = array(
494 'id' => (string) $action['id'],
495 'label' => (string) ( $action['label'] ?? $action['id'] ),
496 'icon' => (string) ( $action['icon'] ?? '' ),
497 'sections' => array_map( 'strval', (array) ( $action['sections'] ?? array() ) ),
498 'mime' => (string) ( $action['mime'] ?? '' ),
499 );
500 }
501 return $out;
502 }
503