PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
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 0.8.9 All 36 releases
desktop-mode / apps / my-wordpress / parts / lists.php

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

537 lines 20.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * My WordPress — the queries and the item dossier.
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 section
8 * CONTAINS: the `WP_Query` / `WP_User_Query` pages, the per-item
9 * authorization, the root-tile counts, and the detail-pane dossier.
10 *
11 * @package OpenStation
12 */
13
14 namespace OpenStation\Apps\MyWordPress;
15
16 use OpenStation\App\Os;
17 use OpenStation\App\State;
18
19 // Direct access, unless a standalone host is booting on bare PHP.
20 if ( ! defined( 'ABSPATH' ) ) {
21 defined( 'OPENSTATION_STANDALONE' ) || exit;
22 }
23
24 const PER_PAGE = 24;
25 const MEDIA_PER_PAGE = 48;
26
27 /**
28 * Whether the acting user may edit / trash one item.
29 *
30 * @param Os $os Host handle.
31 * @param array<string,mixed> $section Section descriptor.
32 * @param int $id Item id.
33 * @param string $verb `edit` | `delete`.
34 * @return bool
35 */
36 function allowed( Os $os, array $section, $id, $verb ) {
37 $woo = woo_allowed( $section, (int) $id, $verb );
38 if ( null !== $woo ) {
39 return $woo;
40 }
41 if ( 'user' === $section['kind'] ) {
42 return 'edit' === $verb && $os->can( 'edit_user', (int) $id );
43 }
44 return $os->can( $verb . '_post', (int) $id );
45 }
46
47 /**
48 * The name of whoever holds the edit lock on a post, '' when free.
49 * WP Explorer's lock payload, reused.
50 *
51 * @param int $post_id Post id.
52 * @return string
53 */
54 function lock_holder( $post_id ) {
55 if ( ! function_exists( 'openstation_my_wordpress_post_lock_payload' ) ) {
56 return '';
57 }
58 $lock = openstation_my_wordpress_post_lock_payload( (int) $post_id );
59 if ( is_array( $lock ) ) {
60 return (string) $lock['userName'];
61 }
62 return '';
63 }
64
65 /**
66 * The REST-visible extras plugin JS reads off a `wp/v2` row: the
67 * registered `show_in_rest` meta values under `meta`, and one
68 * term-id array per REST-exposed taxonomy, keyed by its `rest_base`
69 * — the exact fields WP Explorer's rows carry, which is what the
70 * shared `os.my-wordpress.*` hook subscribers (band assigners,
71 * preview-extras painters) read their facts from.
72 *
73 * @param \WP_Post $post Post.
74 * @return array<string,mixed>
75 */
76 function rest_extras( \WP_Post $post ) {
77 $meta = array();
78 foreach ( get_registered_meta_keys( 'post', (string) $post->post_type ) as $key => $args ) {
79 if ( ! empty( $args['show_in_rest'] ) ) {
80 $meta[ $key ] = get_post_meta( $post->ID, $key, true );
81 }
82 }
83 // An empty PHP array JSON-encodes as `[]`; subscribers index
84 // `item.meta[ key ]`, which wants an object either way.
85 $extras = array( 'meta' => array() === $meta ? new \stdClass() : $meta );
86 foreach ( get_object_taxonomies( $post->post_type, 'objects' ) as $taxonomy ) {
87 if ( empty( $taxonomy->show_in_rest ) ) {
88 continue;
89 }
90 $base = isset( $taxonomy->rest_base ) && '' !== (string) $taxonomy->rest_base
91 ? (string) $taxonomy->rest_base
92 : (string) $taxonomy->name;
93 $ids = wp_get_object_terms( $post->ID, $taxonomy->name, array( 'fields' => 'ids' ) );
94 $extras[ $base ] = is_wp_error( $ids ) ? array() : array_map( 'intval', $ids );
95 }
96 return $extras;
97 }
98
99 /**
100 * A primary order with the ID tiebreak behind it — unless the primary
101 * order IS the id, where a second `ID` key would silently replace the
102 * first (PHP array keys are unique) and flip the direction asked for.
103 *
104 * @param string $by Primary `orderby`.
105 * @param string $order Its direction.
106 * @param string $tiebreak Direction of the ID tiebreak.
107 * @return array<string,string>
108 */
109 function tiebroken( $by, $order, $tiebreak ) {
110 if ( 'ID' === $by ) {
111 return array( 'ID' => $order );
112 }
113 return array(
114 $by => $order,
115 'ID' => $tiebreak,
116 );
117 }
118
119 /**
120 * One page of a section, in the uniform shape the client view
121 * renders: `items` (each: `id`, `title`, `subtitle`, `status`,
122 * `thumb`, `link`, `mime`, `lockedBy`, `canEdit`, `canDelete`, plus
123 * the REST-visible extras for post kinds), `total`, `pages`, `page`.
124 *
125 * @param Os $os Host handle.
126 * @param array<string,mixed> $section Section descriptor.
127 * @param State $state State (`query`, `page`, `sort`).
128 * @return array{items:array[],total:int,pages:int,page:int}
129 */
130 function fetch( Os $os, array $section, State $state ) {
131 // The Woo sections own their pages whole: Orders walk the status
132 // bands through `wc_get_orders()` (HPOS keeps them out of
133 // `wp_posts`), Customers replay the band-and-spend plan.
134 $woo_page = woo_list( $os, $section, $state );
135 if ( null !== $woo_page ) {
136 return $woo_page;
137 }
138
139 $query = (string) $state->get( 'query' );
140 $page = max( 1, (int) $state->get( 'page' ) );
141 list( $by, $order ) = sort_of( $section, $state );
142
143 if ( 'user' === $section['kind'] ) {
144 $users = new \WP_User_Query(
145 array(
146 'number' => PER_PAGE,
147 'offset' => ( $page - 1 ) * PER_PAGE,
148 'search' => '' !== $query ? '*' . $query . '*' : '',
149 // The ID tiebreak is load-bearing: rows equal on the
150 // primary sort (duplicate display names, same-second
151 // registrations) have NO defined order without it, so
152 // each page's query may resort the whole set differently
153 // and an infinite-scrolled list visibly reshuffles as
154 // pages land.
155 'orderby' => tiebroken( $by, $order, 'ASC' ),
156 'count_total' => true,
157 )
158 );
159 $results = $users->get_results();
160 // One query for every row's post count — the list view's Posts
161 // column — instead of one per person.
162 $counts = count_many_users_posts( wp_list_pluck( $results, 'ID' ), 'post', true );
163 $items = array();
164 foreach ( $results as $user ) {
165 $items[] = user_row(
166 $user,
167 static function ( $user_id ) use ( $os, $section ) {
168 return allowed( $os, $section, $user_id, 'edit' );
169 },
170 (int) ( $counts[ $user->ID ] ?? 0 )
171 );
172 }
173 return Os::page( $items, $users->get_total(), $page, PER_PAGE );
174 }
175
176 $is_media = 'media' === $section['kind'];
177 $per_page = $is_media ? MEDIA_PER_PAGE : PER_PAGE;
178 $args = array(
179 'post_type' => (string) $section['post_type'],
180 'post_status' => $is_media ? 'inherit' : statuses(),
181 's' => $query,
182 'posts_per_page' => $per_page,
183 'paged' => $page,
184 // ID tiebreak: demo and imported content routinely shares
185 // one post_date to the second, and equal rows have no
186 // defined order — each page could resort the set and the
187 // infinite scroll would reshuffle. See the user query above.
188 'orderby' => tiebroken( $by, $order, 'DESC' ),
189 );
190 // Products and Coupons arrive in band order, decided server-side
191 // by the same cached plans that order them for WP Explorer.
192 $posts = new \WP_Query( woo_query_args( $args, $section, $state ) );
193 $items = array();
194 foreach ( $posts->posts as $post ) {
195 $items[] = array(
196 'id' => (int) $post->ID,
197 'title' => '' !== $post->post_title ? openstation_plain_text_title( $post->post_title ) : __( '(no title)', 'desktop-mode' ),
198 'subtitle' => $is_media
199 ? (string) $post->post_mime_type
200 : sprintf(
201 /* translators: 1: author display name, 2: date. */
202 __( '%1$s — %2$s', 'desktop-mode' ),
203 openstation_plain_text_title( get_the_author_meta( 'display_name', (int) $post->post_author ) ),
204 (string) get_the_date( '', $post )
205 ),
206 'status' => $is_media ? '' : (string) $post->post_status,
207 // For the hover card — the original tooltip's excerpt,
208 // already clamped to the 240 characters it shows.
209 // Stripped of tags AND decoded: the excerpt arrives with
210 // entities (`[&hellip;]` from `excerpt_more`, `&amp;` from
211 // the content), and the client sets it as text, where an
212 // undecoded entity reads literally.
213 'excerpt' => $is_media
214 ? ''
215 : mb_substr(
216 html_entity_decode( wp_strip_all_tags( (string) get_the_excerpt( $post ) ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ),
217 0,
218 240
219 ),
220 'thumb' => ! empty( $section['thumbnails'] )
221 ? ( $is_media
222 ? (string) wp_get_attachment_image_url( $post->ID, 'medium' )
223 : (string) get_the_post_thumbnail_url( $post, 'thumbnail' ) )
224 : '',
225 'link' => esc_url_raw( $is_media ? (string) wp_get_attachment_url( $post->ID ) : (string) get_permalink( $post ) ),
226 'mime' => $is_media ? (string) $post->post_mime_type : '',
227 'lockedBy' => $is_media ? '' : lock_holder( $post->ID ),
228 'canEdit' => allowed( $os, $section, (int) $post->ID, 'edit' ),
229 'canDelete' => allowed( $os, $section, (int) $post->ID, 'delete' ),
230 );
231 // The list view's columns — the facts a person who thinks in
232 // ids needs at a glance, and the ones that tell a `-2` slug
233 // from the post it shadows.
234 $items[ count( $items ) - 1 ] += $is_media ? media_facts( $post ) : post_facts( $post );
235 if ( ! $is_media ) {
236 // The base keys stay authoritative: an extras key that
237 // collides with one of ours (a taxonomy rest_base named
238 // `status`, say) must not overwrite it.
239 $items[ count( $items ) - 1 ] += rest_extras( $post );
240 // The `openstation_woo` facts the shared band assigner and
241 // stock-ribbon decorator read. Empty without WooCommerce.
242 $items[ count( $items ) - 1 ] += woo_extras( $post );
243 }
244 }
245 return Os::page( $items, $posts->found_posts, $page, $per_page );
246 }
247
248 /**
249 * The list-view facts of a post-kind row: the slug, the author, both
250 * dates as ISO-8601 with the site offset (so the client formats them
251 * in the viewer's locale), the comment count, the `?p=` shortlink
252 * that survives a permalink change, the parent, and the word count.
253 *
254 * @param \WP_Post $post Post.
255 * @return array<string,mixed>
256 */
257 function post_facts( \WP_Post $post ) {
258 $parent = (int) $post->post_parent;
259 $shortlink = (string) wp_get_shortlink( $post->ID );
260 return array(
261 'slug' => (string) $post->post_name,
262 'author' => openstation_plain_text_title( get_the_author_meta( 'display_name', (int) $post->post_author ) ),
263 'authorId' => (int) $post->post_author,
264 'date' => (string) get_the_date( 'c', $post ),
265 'modified' => (string) get_the_modified_date( 'c', $post ),
266 'comments' => (int) $post->comment_count,
267 'shortlink' => '' !== $shortlink ? esc_url_raw( $shortlink ) : '',
268 'parent' => $parent,
269 'parentTitle' => $parent > 0 ? openstation_plain_text_title( get_the_title( $parent ) ) : '',
270 'words' => str_word_count( wp_strip_all_tags( (string) $post->post_content ) ),
271 );
272 }
273
274 /**
275 * The list-view facts of a media row: the file name, its size and
276 * dimensions, both dates, and the post it is attached to.
277 *
278 * @param \WP_Post $post Attachment.
279 * @return array<string,mixed>
280 */
281 function media_facts( \WP_Post $post ) {
282 $file = get_attached_file( $post->ID );
283 $meta = (array) wp_get_attachment_metadata( $post->ID );
284 $bytes = $file && file_exists( $file ) ? (int) filesize( $file ) : 0;
285 $parent = (int) $post->post_parent;
286 return array(
287 'slug' => (string) $post->post_name,
288 'file' => $file ? wp_basename( $file ) : '',
289 'bytes' => $bytes,
290 'size' => $bytes > 0 ? (string) size_format( $bytes ) : '',
291 'dimensions' => isset( $meta['width'], $meta['height'] ) ? $meta['width'] . ' × ' . $meta['height'] : '',
292 'alt' => (string) get_post_meta( $post->ID, '_wp_attachment_image_alt', true ),
293 'author' => openstation_plain_text_title( get_the_author_meta( 'display_name', (int) $post->post_author ) ),
294 'authorId' => (int) $post->post_author,
295 'date' => (string) get_the_date( 'c', $post ),
296 'modified' => (string) get_the_modified_date( 'c', $post ),
297 'parent' => $parent,
298 'parentTitle' => $parent > 0 ? openstation_plain_text_title( get_the_title( $parent ) ) : '',
299 );
300 }
301
302 /**
303 * One list row for a WP_User — the REST-visible shape both the Users
304 * section and WooCommerce's Customers section paint. One definition,
305 * so the two lists can never drift apart field by field.
306 *
307 * @param \WP_User $user The user.
308 * @param callable $can_edit Answers "may the viewer edit user $id?".
309 * @param int|null $post_count Their published post count, when the
310 * caller already counted the page in one
311 * query; null counts this one person.
312 * @return array<string,mixed>
313 */
314 function user_row( \WP_User $user, callable $can_edit, $post_count = null ) {
315 return array(
316 'id' => (int) $user->ID,
317 'title' => openstation_plain_text_title( $user->display_name ),
318 // The REST spelling too — the shared seam subscribers were
319 // written against `/wp/v2/users` rows.
320 'name' => openstation_plain_text_title( $user->display_name ),
321 'subtitle' => (string) $user->user_email,
322 'status' => implode( ', ', array_map( 'ucfirst', (array) $user->roles ) ),
323 'excerpt' => '',
324 'thumb' => (string) get_avatar_url( $user->ID, array( 'size' => 96 ) ),
325 'link' => esc_url_raw( get_author_posts_url( $user->ID ) ),
326 'mime' => '',
327 'lockedBy' => '',
328 'canEdit' => (bool) $can_edit( (int) $user->ID ),
329 'canDelete' => false,
330 // The list view's columns.
331 'login' => (string) $user->user_login,
332 'email' => (string) $user->user_email,
333 'roles' => array_values( array_map( 'strval', (array) $user->roles ) ),
334 // Stored in GMT; shipped as site-local ISO with its offset.
335 'registered' => (string) get_date_from_gmt( (string) $user->user_registered, 'c' ),
336 'posts' => null === $post_count
337 ? (int) count_user_posts( $user->ID, 'post', true )
338 : (int) $post_count,
339 );
340 }
341
342 /**
343 * How many things a section holds, for the root tiles.
344 *
345 * @param array<string,mixed> $section Section descriptor.
346 * @return int
347 */
348 function count_of( array $section ) {
349 $woo = woo_count( $section );
350 if ( null !== $woo ) {
351 return $woo;
352 }
353 if ( 'agent' === $section['kind'] ) {
354 // Zero while the framework is off: the five shipped agents are
355 // only seeded when the flag flips, so none of them exist yet.
356 return function_exists( 'openstation_agent_get_agents' )
357 ? count( openstation_agent_get_agents() )
358 : 0;
359 }
360 if ( 'user' === $section['kind'] ) {
361 $counts = count_users();
362 return (int) $counts['total_users'];
363 }
364 $counts = wp_count_posts( (string) $section['post_type'] );
365 if ( 'media' === $section['kind'] ) {
366 return (int) ( $counts->inherit ?? 0 );
367 }
368 $total = 0;
369 foreach ( statuses() as $status ) {
370 $total += (int) ( $counts->$status ?? 0 );
371 }
372 return $total;
373 }
374
375 /**
376 * The admin URL that edits one item of a section.
377 *
378 * @param array<string,mixed> $section Section descriptor.
379 * @param int $id Item id.
380 * @return string
381 */
382 function edit_url( array $section, $id ) {
383 $woo = woo_edit_url( $section, (int) $id );
384 if ( '' !== $woo ) {
385 return $woo;
386 }
387 if ( 'user' === $section['kind'] ) {
388 return admin_url( 'user-edit.php?user_id=' . (int) $id );
389 }
390 return admin_url( 'post.php?post=' . (int) $id . '&action=edit' );
391 }
392
393 /**
394 * The title a window opened on an item's editor should wear.
395 *
396 * An iframe never reports its own title, so a window opened on a bare
397 * URL keeps that URL as its name for as long as it lives — on a phone
398 * the top bar then reads `…/wp-admin/post.php?post=…`. The item's own
399 * title is what the user tapped; the window says the same, decoded:
400 * stored titles and display names carry entities (`&amp;`).
401 *
402 * @param array<string,mixed> $section Section descriptor.
403 * @param int $id Item id.
404 * @return string The title, or '' when the item is gone.
405 */
406 function edit_title( array $section, $id ) {
407 // An order's post is WooCommerce's `Order &ndash; <date>` stamp, or
408 // an untitled placeholder without sync: it wears its pane's title.
409 if ( woo_section_is( $section, 'wc-orders' ) ) {
410 $detail = woo_detail( $section, (int) $id );
411 return $detail ? (string) $detail['title'] : '';
412 }
413 if ( 'user' === $section['kind'] ) {
414 $user = get_userdata( (int) $id );
415 return $user ? openstation_plain_text_title( $user->display_name ) : '';
416 }
417 $post = get_post( (int) $id );
418 if ( ! $post ) {
419 return '';
420 }
421 $title = openstation_plain_text_title( $post->post_title );
422 return '' !== $title ? $title : __( '(no title)', 'desktop-mode' );
423 }
424
425 /**
426 * The dossier payload for the open item — everything the detail pane
427 * paints, per kind.
428 *
429 * @param Os $os Host handle.
430 * @param array<string,mixed> $section Section descriptor.
431 * @param int $id Item id.
432 * @return array<string,mixed>|null Null when the item vanished.
433 */
434 function detail( Os $os, array $section, $id ) {
435 if ( woo_section_is( $section, 'wc-orders' ) ) {
436 return woo_detail( $section, $id );
437 }
438 if ( 'user' === $section['kind'] ) {
439 $user = get_userdata( $id );
440 if ( ! $user ) {
441 return null;
442 }
443 // The third element tags each fact with WP Explorer's dossier
444 // section id, so the shared
445 // `os.my-wordpress.user-dossier-sections` filter can drop
446 // whole blocks — a customer's publishing stats are four zeroes
447 // above the number the merchant actually came for. The counts
448 // themselves ride `stats` below: the SAME aggregated dossier
449 // WP Explorer's `/user-stats/<id>` route serves (stat tiles,
450 // 12-month activity, milestones, recent posts, top terms),
451 // through the same callback and its filters.
452 return array(
453 'kind' => 'user',
454 'id' => $id,
455 'title' => openstation_plain_text_title( $user->display_name ),
456 'avatar' => (string) get_avatar_url( $id, array( 'size' => 192 ) ),
457 'facts' => Os::facts(
458 array(
459 array( __( 'Email', 'desktop-mode' ), (string) $user->user_email, 'bio' ),
460 array( __( 'Role', 'desktop-mode' ), implode( ', ', array_map( 'ucfirst', (array) $user->roles ) ), 'bio' ),
461 array( __( 'Registered', 'desktop-mode' ), (string) date_i18n( get_option( 'date_format' ), strtotime( $user->user_registered ) ), 'bio' ),
462 )
463 ),
464 'stats' => stats_payload( 'openstation_my_wordpress_user_stats_callback', array( 'id' => $id ) ),
465 'canEdit' => allowed( $os, $section, $id, 'edit' ),
466 'canDelete' => false,
467 );
468 }
469
470 $post = get_post( $id );
471 if ( ! $post || $post->post_type !== $section['post_type'] ) {
472 return null;
473 }
474 $title = '' !== $post->post_title ? openstation_plain_text_title( $post->post_title ) : __( '(no title)', 'desktop-mode' );
475
476 if ( 'media' === $section['kind'] ) {
477 $file = get_attached_file( $id );
478 $meta = (array) wp_get_attachment_metadata( $id );
479 $used = array();
480 if ( function_exists( 'openstation_my_wordpress_media_usage_build' ) ) {
481 foreach ( array_slice( (array) ( openstation_my_wordpress_media_usage_build( $post )['usedIn'] ?? array() ), 0, 12 ) as $row ) {
482 $used[] = array(
483 'title' => openstation_plain_text_title( $row['title'] ?? '' ),
484 'usedAs' => (string) ( $row['usedAs'] ?? '' ),
485 );
486 }
487 }
488 return array(
489 'kind' => 'media',
490 'id' => $id,
491 'title' => $title,
492 'mime' => (string) $post->post_mime_type,
493 'image' => (string) wp_get_attachment_image_url( $id, 'large' ),
494 'full' => (string) wp_get_attachment_image_url( $id, 'full' ),
495 'facts' => Os::facts(
496 array(
497 array( __( 'Type', 'desktop-mode' ), (string) $post->post_mime_type ),
498 array( __( 'Size', 'desktop-mode' ), $file && file_exists( $file ) ? (string) size_format( (int) filesize( $file ) ) : '' ),
499 array(
500 __( 'Dimensions', 'desktop-mode' ),
501 isset( $meta['width'], $meta['height'] ) ? $meta['width'] . ' × ' . $meta['height'] : '',
502 ),
503 array( __( 'Uploaded', 'desktop-mode' ), (string) get_the_date( '', $post ) ),
504 )
505 ),
506 'usedIn' => $used,
507 'canEdit' => allowed( $os, $section, $id, 'edit' ),
508 'canDelete' => allowed( $os, $section, $id, 'delete' ),
509 );
510 }
511
512 // The rendered body — what WP Explorer's preview pane shows.
513 // Server-rendered, admin-trusted, injected verbatim by the client.
514 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying Core's own content pipeline (blocks, shortcodes, embeds), not declaring a hook.
515 $content = apply_filters( 'the_content', (string) $post->post_content );
516
517 return array(
518 'kind' => 'post',
519 'id' => $id,
520 'title' => $title,
521 'image' => (string) get_the_post_thumbnail_url( $post, 'large' ),
522 'content' => (string) $content,
523 'lockedBy' => lock_holder( $id ),
524 'facts' => Os::facts(
525 array(
526 array( __( 'Status', 'desktop-mode' ), ucfirst( (string) $post->post_status ) ),
527 array( __( 'Author', 'desktop-mode' ), openstation_plain_text_title( get_the_author_meta( 'display_name', (int) $post->post_author ) ) ),
528 array( __( 'Published', 'desktop-mode' ), (string) get_the_date( '', $post ) ),
529 array( __( 'Modified', 'desktop-mode' ), (string) get_the_modified_date( '', $post ) ),
530 array( __( 'Words', 'desktop-mode' ), number_format_i18n( str_word_count( wp_strip_all_tags( (string) $post->post_content ) ) ) ),
531 )
532 ),
533 'canEdit' => allowed( $os, $section, $id, 'edit' ),
534 'canDelete' => allowed( $os, $section, $id, 'delete' ),
535 );
536 }
537