PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
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 / apps / my-wordpress / parts / lists.php

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

528 lines 20.0 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 ) && ! empty( $lock['locked'] ) && ! empty( $lock['name'] ) ) {
60 return (string) $lock['name'];
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 ? (string) $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 (string) 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' => (string) 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 ? (string) 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 'author' => (string) get_the_author_meta( 'display_name', (int) $post->post_author ),
293 'authorId' => (int) $post->post_author,
294 'date' => (string) get_the_date( 'c', $post ),
295 'modified' => (string) get_the_modified_date( 'c', $post ),
296 'parent' => $parent,
297 'parentTitle' => $parent > 0 ? (string) get_the_title( $parent ) : '',
298 );
299 }
300
301 /**
302 * One list row for a WP_User — the REST-visible shape both the Users
303 * section and WooCommerce's Customers section paint. One definition,
304 * so the two lists can never drift apart field by field.
305 *
306 * @param \WP_User $user The user.
307 * @param callable $can_edit Answers "may the viewer edit user $id?".
308 * @param int|null $post_count Their published post count, when the
309 * caller already counted the page in one
310 * query; null counts this one person.
311 * @return array<string,mixed>
312 */
313 function user_row( \WP_User $user, callable $can_edit, $post_count = null ) {
314 return array(
315 'id' => (int) $user->ID,
316 'title' => (string) $user->display_name,
317 // The REST spelling too — the shared seam subscribers were
318 // written against `/wp/v2/users` rows.
319 'name' => (string) $user->display_name,
320 'subtitle' => (string) $user->user_email,
321 'status' => implode( ', ', array_map( 'ucfirst', (array) $user->roles ) ),
322 'excerpt' => '',
323 'thumb' => (string) get_avatar_url( $user->ID, array( 'size' => 96 ) ),
324 'link' => esc_url_raw( get_author_posts_url( $user->ID ) ),
325 'mime' => '',
326 'lockedBy' => '',
327 'canEdit' => (bool) $can_edit( (int) $user->ID ),
328 'canDelete' => false,
329 // The list view's columns.
330 'login' => (string) $user->user_login,
331 'email' => (string) $user->user_email,
332 'roles' => array_values( array_map( 'strval', (array) $user->roles ) ),
333 // Stored in GMT; shipped as site-local ISO with its offset.
334 'registered' => (string) get_date_from_gmt( (string) $user->user_registered, 'c' ),
335 'posts' => null === $post_count
336 ? (int) count_user_posts( $user->ID, 'post', true )
337 : (int) $post_count,
338 );
339 }
340
341 /**
342 * How many things a section holds, for the root tiles.
343 *
344 * @param array<string,mixed> $section Section descriptor.
345 * @return int
346 */
347 function count_of( array $section ) {
348 $woo = woo_count( $section );
349 if ( null !== $woo ) {
350 return $woo;
351 }
352 if ( 'agent' === $section['kind'] ) {
353 // Zero while the framework is off: the five shipped agents are
354 // only seeded when the flag flips, so none of them exist yet.
355 return function_exists( 'openstation_agent_get_agents' )
356 ? count( openstation_agent_get_agents() )
357 : 0;
358 }
359 if ( 'user' === $section['kind'] ) {
360 $counts = count_users();
361 return (int) $counts['total_users'];
362 }
363 $counts = wp_count_posts( (string) $section['post_type'] );
364 if ( 'media' === $section['kind'] ) {
365 return (int) ( $counts->inherit ?? 0 );
366 }
367 $total = 0;
368 foreach ( statuses() as $status ) {
369 $total += (int) ( $counts->$status ?? 0 );
370 }
371 return $total;
372 }
373
374 /**
375 * The admin URL that edits one item of a section.
376 *
377 * @param array<string,mixed> $section Section descriptor.
378 * @param int $id Item id.
379 * @return string
380 */
381 function edit_url( array $section, $id ) {
382 $woo = woo_edit_url( $section, (int) $id );
383 if ( '' !== $woo ) {
384 return $woo;
385 }
386 if ( 'user' === $section['kind'] ) {
387 return admin_url( 'user-edit.php?user_id=' . (int) $id );
388 }
389 return admin_url( 'post.php?post=' . (int) $id . '&action=edit' );
390 }
391
392 /**
393 * The title a window opened on an item's editor should wear.
394 *
395 * An iframe never reports its own title, so a window opened on a bare
396 * URL keeps that URL as its name for as long as it lives — on a phone
397 * the top bar then reads `…/wp-admin/post.php?post=…`. The item's own
398 * title is what the user tapped; the window says the same.
399 *
400 * @param array<string,mixed> $section Section descriptor.
401 * @param int $id Item id.
402 * @return string The title, or '' when the item is gone.
403 */
404 function edit_title( array $section, $id ) {
405 if ( 'user' === $section['kind'] ) {
406 $user = get_userdata( (int) $id );
407 return $user ? (string) $user->display_name : '';
408 }
409 $post = get_post( (int) $id );
410 if ( ! $post ) {
411 return '';
412 }
413 return '' !== $post->post_title ? (string) $post->post_title : __( '(no title)', 'desktop-mode' );
414 }
415
416 /**
417 * The dossier payload for the open item — everything the detail pane
418 * paints, per kind.
419 *
420 * @param Os $os Host handle.
421 * @param array<string,mixed> $section Section descriptor.
422 * @param int $id Item id.
423 * @return array<string,mixed>|null Null when the item vanished.
424 */
425 function detail( Os $os, array $section, $id ) {
426 if ( woo_section_is( $section, 'wc-orders' ) ) {
427 return woo_detail( $section, $id );
428 }
429 if ( 'user' === $section['kind'] ) {
430 $user = get_userdata( $id );
431 if ( ! $user ) {
432 return null;
433 }
434 // The third element tags each fact with WP Explorer's dossier
435 // section id, so the shared
436 // `os.my-wordpress.user-dossier-sections` filter can drop
437 // whole blocks — a customer's publishing stats are four zeroes
438 // above the number the merchant actually came for. The counts
439 // themselves ride `stats` below: the SAME aggregated dossier
440 // WP Explorer's `/user-stats/<id>` route serves (stat tiles,
441 // 12-month activity, milestones, recent posts, top terms),
442 // through the same callback and its filters.
443 return array(
444 'kind' => 'user',
445 'id' => $id,
446 'title' => (string) $user->display_name,
447 'avatar' => (string) get_avatar_url( $id, array( 'size' => 192 ) ),
448 'facts' => Os::facts(
449 array(
450 array( __( 'Email', 'desktop-mode' ), (string) $user->user_email, 'bio' ),
451 array( __( 'Role', 'desktop-mode' ), implode( ', ', array_map( 'ucfirst', (array) $user->roles ) ), 'bio' ),
452 array( __( 'Registered', 'desktop-mode' ), (string) date_i18n( get_option( 'date_format' ), strtotime( $user->user_registered ) ), 'bio' ),
453 )
454 ),
455 'stats' => stats_payload( 'openstation_my_wordpress_user_stats_callback', array( 'id' => $id ) ),
456 'canEdit' => allowed( $os, $section, $id, 'edit' ),
457 'canDelete' => false,
458 );
459 }
460
461 $post = get_post( $id );
462 if ( ! $post || $post->post_type !== $section['post_type'] ) {
463 return null;
464 }
465 $title = '' !== $post->post_title ? (string) $post->post_title : __( '(no title)', 'desktop-mode' );
466
467 if ( 'media' === $section['kind'] ) {
468 $file = get_attached_file( $id );
469 $meta = (array) wp_get_attachment_metadata( $id );
470 $used = array();
471 if ( function_exists( 'openstation_my_wordpress_media_usage_build' ) ) {
472 foreach ( array_slice( (array) ( openstation_my_wordpress_media_usage_build( $post )['usedIn'] ?? array() ), 0, 12 ) as $row ) {
473 $used[] = array(
474 'title' => (string) ( $row['title'] ?? '' ),
475 'usedAs' => (string) ( $row['usedAs'] ?? '' ),
476 );
477 }
478 }
479 return array(
480 'kind' => 'media',
481 'id' => $id,
482 'title' => $title,
483 'mime' => (string) $post->post_mime_type,
484 'image' => (string) wp_get_attachment_image_url( $id, 'large' ),
485 'full' => (string) wp_get_attachment_image_url( $id, 'full' ),
486 'facts' => Os::facts(
487 array(
488 array( __( 'Type', 'desktop-mode' ), (string) $post->post_mime_type ),
489 array( __( 'Size', 'desktop-mode' ), $file && file_exists( $file ) ? (string) size_format( (int) filesize( $file ) ) : '' ),
490 array(
491 __( 'Dimensions', 'desktop-mode' ),
492 isset( $meta['width'], $meta['height'] ) ? $meta['width'] . ' × ' . $meta['height'] : '',
493 ),
494 array( __( 'Uploaded', 'desktop-mode' ), (string) get_the_date( '', $post ) ),
495 )
496 ),
497 'usedIn' => $used,
498 'canEdit' => allowed( $os, $section, $id, 'edit' ),
499 'canDelete' => allowed( $os, $section, $id, 'delete' ),
500 );
501 }
502
503 // The rendered body — what WP Explorer's preview pane shows.
504 // Server-rendered, admin-trusted, injected verbatim by the client.
505 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying Core's own content pipeline (blocks, shortcodes, embeds), not declaring a hook.
506 $content = apply_filters( 'the_content', (string) $post->post_content );
507
508 return array(
509 'kind' => 'post',
510 'id' => $id,
511 'title' => $title,
512 'image' => (string) get_the_post_thumbnail_url( $post, 'large' ),
513 'content' => (string) $content,
514 'lockedBy' => lock_holder( $id ),
515 'facts' => Os::facts(
516 array(
517 array( __( 'Status', 'desktop-mode' ), ucfirst( (string) $post->post_status ) ),
518 array( __( 'Author', 'desktop-mode' ), (string) get_the_author_meta( 'display_name', (int) $post->post_author ) ),
519 array( __( 'Published', 'desktop-mode' ), (string) get_the_date( '', $post ) ),
520 array( __( 'Modified', 'desktop-mode' ), (string) get_the_modified_date( '', $post ) ),
521 array( __( 'Words', 'desktop-mode' ), number_format_i18n( str_word_count( wp_strip_all_tags( (string) $post->post_content ) ) ) ),
522 )
523 ),
524 'canEdit' => allowed( $os, $section, $id, 'edit' ),
525 'canDelete' => allowed( $os, $section, $id, 'delete' ),
526 );
527 }
528