$section Section descriptor. * @param int $id Item id. * @param string $verb `edit` | `delete`. * @return bool */ function allowed( Os $os, array $section, $id, $verb ) { $woo = woo_allowed( $section, (int) $id, $verb ); if ( null !== $woo ) { return $woo; } if ( 'user' === $section['kind'] ) { return 'edit' === $verb && $os->can( 'edit_user', (int) $id ); } return $os->can( $verb . '_post', (int) $id ); } /** * The name of whoever holds the edit lock on a post, '' when free. * WP Explorer's lock payload, reused. * * @param int $post_id Post id. * @return string */ function lock_holder( $post_id ) { if ( ! function_exists( 'openstation_my_wordpress_post_lock_payload' ) ) { return ''; } $lock = openstation_my_wordpress_post_lock_payload( (int) $post_id ); if ( is_array( $lock ) && ! empty( $lock['locked'] ) && ! empty( $lock['name'] ) ) { return (string) $lock['name']; } return ''; } /** * The REST-visible extras plugin JS reads off a `wp/v2` row: the * registered `show_in_rest` meta values under `meta`, and one * term-id array per REST-exposed taxonomy, keyed by its `rest_base` * — the exact fields WP Explorer's rows carry, which is what the * shared `os.my-wordpress.*` hook subscribers (band assigners, * preview-extras painters) read their facts from. * * @param \WP_Post $post Post. * @return array */ function rest_extras( \WP_Post $post ) { $meta = array(); foreach ( get_registered_meta_keys( 'post', (string) $post->post_type ) as $key => $args ) { if ( ! empty( $args['show_in_rest'] ) ) { $meta[ $key ] = get_post_meta( $post->ID, $key, true ); } } // An empty PHP array JSON-encodes as `[]`; subscribers index // `item.meta[ key ]`, which wants an object either way. $extras = array( 'meta' => array() === $meta ? new \stdClass() : $meta ); foreach ( get_object_taxonomies( $post->post_type, 'objects' ) as $taxonomy ) { if ( empty( $taxonomy->show_in_rest ) ) { continue; } $base = isset( $taxonomy->rest_base ) && '' !== (string) $taxonomy->rest_base ? (string) $taxonomy->rest_base : (string) $taxonomy->name; $ids = wp_get_object_terms( $post->ID, $taxonomy->name, array( 'fields' => 'ids' ) ); $extras[ $base ] = is_wp_error( $ids ) ? array() : array_map( 'intval', $ids ); } return $extras; } /** * A primary order with the ID tiebreak behind it — unless the primary * order IS the id, where a second `ID` key would silently replace the * first (PHP array keys are unique) and flip the direction asked for. * * @param string $by Primary `orderby`. * @param string $order Its direction. * @param string $tiebreak Direction of the ID tiebreak. * @return array */ function tiebroken( $by, $order, $tiebreak ) { if ( 'ID' === $by ) { return array( 'ID' => $order ); } return array( $by => $order, 'ID' => $tiebreak, ); } /** * One page of a section, in the uniform shape the client view * renders: `items` (each: `id`, `title`, `subtitle`, `status`, * `thumb`, `link`, `mime`, `lockedBy`, `canEdit`, `canDelete`, plus * the REST-visible extras for post kinds), `total`, `pages`, `page`. * * @param Os $os Host handle. * @param array $section Section descriptor. * @param State $state State (`query`, `page`, `sort`). * @return array{items:array[],total:int,pages:int,page:int} */ function fetch( Os $os, array $section, State $state ) { // The Woo sections own their pages whole: Orders walk the status // bands through `wc_get_orders()` (HPOS keeps them out of // `wp_posts`), Customers replay the band-and-spend plan. $woo_page = woo_list( $os, $section, $state ); if ( null !== $woo_page ) { return $woo_page; } $query = (string) $state->get( 'query' ); $page = max( 1, (int) $state->get( 'page' ) ); list( $by, $order ) = sort_of( $section, $state ); if ( 'user' === $section['kind'] ) { $users = new \WP_User_Query( array( 'number' => PER_PAGE, 'offset' => ( $page - 1 ) * PER_PAGE, 'search' => '' !== $query ? '*' . $query . '*' : '', // The ID tiebreak is load-bearing: rows equal on the // primary sort (duplicate display names, same-second // registrations) have NO defined order without it, so // each page's query may resort the whole set differently // and an infinite-scrolled list visibly reshuffles as // pages land. 'orderby' => tiebroken( $by, $order, 'ASC' ), 'count_total' => true, ) ); $results = $users->get_results(); // One query for every row's post count — the list view's Posts // column — instead of one per person. $counts = count_many_users_posts( wp_list_pluck( $results, 'ID' ), 'post', true ); $items = array(); foreach ( $results as $user ) { $items[] = user_row( $user, static function ( $user_id ) use ( $os, $section ) { return allowed( $os, $section, $user_id, 'edit' ); }, (int) ( $counts[ $user->ID ] ?? 0 ) ); } return Os::page( $items, $users->get_total(), $page, PER_PAGE ); } $is_media = 'media' === $section['kind']; $per_page = $is_media ? MEDIA_PER_PAGE : PER_PAGE; $args = array( 'post_type' => (string) $section['post_type'], 'post_status' => $is_media ? 'inherit' : statuses(), 's' => $query, 'posts_per_page' => $per_page, 'paged' => $page, // ID tiebreak: demo and imported content routinely shares // one post_date to the second, and equal rows have no // defined order — each page could resort the set and the // infinite scroll would reshuffle. See the user query above. 'orderby' => tiebroken( $by, $order, 'DESC' ), ); // Products and Coupons arrive in band order, decided server-side // by the same cached plans that order them for WP Explorer. $posts = new \WP_Query( woo_query_args( $args, $section, $state ) ); $items = array(); foreach ( $posts->posts as $post ) { $items[] = array( 'id' => (int) $post->ID, 'title' => '' !== $post->post_title ? (string) $post->post_title : __( '(no title)', 'desktop-mode' ), 'subtitle' => $is_media ? (string) $post->post_mime_type : sprintf( /* translators: 1: author display name, 2: date. */ __( '%1$s — %2$s', 'desktop-mode' ), (string) get_the_author_meta( 'display_name', (int) $post->post_author ), (string) get_the_date( '', $post ) ), 'status' => $is_media ? '' : (string) $post->post_status, // For the hover card — the original tooltip's excerpt, // already clamped to the 240 characters it shows. // Stripped of tags AND decoded: the excerpt arrives with // entities (`[…]` from `excerpt_more`, `&` from // the content), and the client sets it as text, where an // undecoded entity reads literally. 'excerpt' => $is_media ? '' : mb_substr( html_entity_decode( wp_strip_all_tags( (string) get_the_excerpt( $post ) ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ), 0, 240 ), 'thumb' => ! empty( $section['thumbnails'] ) ? ( $is_media ? (string) wp_get_attachment_image_url( $post->ID, 'medium' ) : (string) get_the_post_thumbnail_url( $post, 'thumbnail' ) ) : '', 'link' => esc_url_raw( $is_media ? (string) wp_get_attachment_url( $post->ID ) : (string) get_permalink( $post ) ), 'mime' => $is_media ? (string) $post->post_mime_type : '', 'lockedBy' => $is_media ? '' : lock_holder( $post->ID ), 'canEdit' => allowed( $os, $section, (int) $post->ID, 'edit' ), 'canDelete' => allowed( $os, $section, (int) $post->ID, 'delete' ), ); // The list view's columns — the facts a person who thinks in // ids needs at a glance, and the ones that tell a `-2` slug // from the post it shadows. $items[ count( $items ) - 1 ] += $is_media ? media_facts( $post ) : post_facts( $post ); if ( ! $is_media ) { // The base keys stay authoritative: an extras key that // collides with one of ours (a taxonomy rest_base named // `status`, say) must not overwrite it. $items[ count( $items ) - 1 ] += rest_extras( $post ); // The `openstation_woo` facts the shared band assigner and // stock-ribbon decorator read. Empty without WooCommerce. $items[ count( $items ) - 1 ] += woo_extras( $post ); } } return Os::page( $items, $posts->found_posts, $page, $per_page ); } /** * The list-view facts of a post-kind row: the slug, the author, both * dates as ISO-8601 with the site offset (so the client formats them * in the viewer's locale), the comment count, the `?p=` shortlink * that survives a permalink change, the parent, and the word count. * * @param \WP_Post $post Post. * @return array */ function post_facts( \WP_Post $post ) { $parent = (int) $post->post_parent; $shortlink = (string) wp_get_shortlink( $post->ID ); return array( 'slug' => (string) $post->post_name, 'author' => (string) get_the_author_meta( 'display_name', (int) $post->post_author ), 'authorId' => (int) $post->post_author, 'date' => (string) get_the_date( 'c', $post ), 'modified' => (string) get_the_modified_date( 'c', $post ), 'comments' => (int) $post->comment_count, 'shortlink' => '' !== $shortlink ? esc_url_raw( $shortlink ) : '', 'parent' => $parent, 'parentTitle' => $parent > 0 ? (string) get_the_title( $parent ) : '', 'words' => str_word_count( wp_strip_all_tags( (string) $post->post_content ) ), ); } /** * The list-view facts of a media row: the file name, its size and * dimensions, both dates, and the post it is attached to. * * @param \WP_Post $post Attachment. * @return array */ function media_facts( \WP_Post $post ) { $file = get_attached_file( $post->ID ); $meta = (array) wp_get_attachment_metadata( $post->ID ); $bytes = $file && file_exists( $file ) ? (int) filesize( $file ) : 0; $parent = (int) $post->post_parent; return array( 'slug' => (string) $post->post_name, 'file' => $file ? wp_basename( $file ) : '', 'bytes' => $bytes, 'size' => $bytes > 0 ? (string) size_format( $bytes ) : '', 'dimensions' => isset( $meta['width'], $meta['height'] ) ? $meta['width'] . ' × ' . $meta['height'] : '', 'author' => (string) get_the_author_meta( 'display_name', (int) $post->post_author ), 'authorId' => (int) $post->post_author, 'date' => (string) get_the_date( 'c', $post ), 'modified' => (string) get_the_modified_date( 'c', $post ), 'parent' => $parent, 'parentTitle' => $parent > 0 ? (string) get_the_title( $parent ) : '', ); } /** * One list row for a WP_User — the REST-visible shape both the Users * section and WooCommerce's Customers section paint. One definition, * so the two lists can never drift apart field by field. * * @param \WP_User $user The user. * @param callable $can_edit Answers "may the viewer edit user $id?". * @param int|null $post_count Their published post count, when the * caller already counted the page in one * query; null counts this one person. * @return array */ function user_row( \WP_User $user, callable $can_edit, $post_count = null ) { return array( 'id' => (int) $user->ID, 'title' => (string) $user->display_name, // The REST spelling too — the shared seam subscribers were // written against `/wp/v2/users` rows. 'name' => (string) $user->display_name, 'subtitle' => (string) $user->user_email, 'status' => implode( ', ', array_map( 'ucfirst', (array) $user->roles ) ), 'excerpt' => '', 'thumb' => (string) get_avatar_url( $user->ID, array( 'size' => 96 ) ), 'link' => esc_url_raw( get_author_posts_url( $user->ID ) ), 'mime' => '', 'lockedBy' => '', 'canEdit' => (bool) $can_edit( (int) $user->ID ), 'canDelete' => false, // The list view's columns. 'login' => (string) $user->user_login, 'email' => (string) $user->user_email, 'roles' => array_values( array_map( 'strval', (array) $user->roles ) ), // Stored in GMT; shipped as site-local ISO with its offset. 'registered' => (string) get_date_from_gmt( (string) $user->user_registered, 'c' ), 'posts' => null === $post_count ? (int) count_user_posts( $user->ID, 'post', true ) : (int) $post_count, ); } /** * How many things a section holds, for the root tiles. * * @param array $section Section descriptor. * @return int */ function count_of( array $section ) { $woo = woo_count( $section ); if ( null !== $woo ) { return $woo; } if ( 'agent' === $section['kind'] ) { // Zero while the framework is off: the five shipped agents are // only seeded when the flag flips, so none of them exist yet. return function_exists( 'openstation_agent_get_agents' ) ? count( openstation_agent_get_agents() ) : 0; } if ( 'user' === $section['kind'] ) { $counts = count_users(); return (int) $counts['total_users']; } $counts = wp_count_posts( (string) $section['post_type'] ); if ( 'media' === $section['kind'] ) { return (int) ( $counts->inherit ?? 0 ); } $total = 0; foreach ( statuses() as $status ) { $total += (int) ( $counts->$status ?? 0 ); } return $total; } /** * The admin URL that edits one item of a section. * * @param array $section Section descriptor. * @param int $id Item id. * @return string */ function edit_url( array $section, $id ) { $woo = woo_edit_url( $section, (int) $id ); if ( '' !== $woo ) { return $woo; } if ( 'user' === $section['kind'] ) { return admin_url( 'user-edit.php?user_id=' . (int) $id ); } return admin_url( 'post.php?post=' . (int) $id . '&action=edit' ); } /** * The title a window opened on an item's editor should wear. * * An iframe never reports its own title, so a window opened on a bare * URL keeps that URL as its name for as long as it lives — on a phone * the top bar then reads `…/wp-admin/post.php?post=…`. The item's own * title is what the user tapped; the window says the same. * * @param array $section Section descriptor. * @param int $id Item id. * @return string The title, or '' when the item is gone. */ function edit_title( array $section, $id ) { if ( 'user' === $section['kind'] ) { $user = get_userdata( (int) $id ); return $user ? (string) $user->display_name : ''; } $post = get_post( (int) $id ); if ( ! $post ) { return ''; } return '' !== $post->post_title ? (string) $post->post_title : __( '(no title)', 'desktop-mode' ); } /** * The dossier payload for the open item — everything the detail pane * paints, per kind. * * @param Os $os Host handle. * @param array $section Section descriptor. * @param int $id Item id. * @return array|null Null when the item vanished. */ function detail( Os $os, array $section, $id ) { if ( woo_section_is( $section, 'wc-orders' ) ) { return woo_detail( $section, $id ); } if ( 'user' === $section['kind'] ) { $user = get_userdata( $id ); if ( ! $user ) { return null; } // The third element tags each fact with WP Explorer's dossier // section id, so the shared // `os.my-wordpress.user-dossier-sections` filter can drop // whole blocks — a customer's publishing stats are four zeroes // above the number the merchant actually came for. The counts // themselves ride `stats` below: the SAME aggregated dossier // WP Explorer's `/user-stats/` route serves (stat tiles, // 12-month activity, milestones, recent posts, top terms), // through the same callback and its filters. return array( 'kind' => 'user', 'id' => $id, 'title' => (string) $user->display_name, 'avatar' => (string) get_avatar_url( $id, array( 'size' => 192 ) ), 'facts' => Os::facts( array( array( __( 'Email', 'desktop-mode' ), (string) $user->user_email, 'bio' ), array( __( 'Role', 'desktop-mode' ), implode( ', ', array_map( 'ucfirst', (array) $user->roles ) ), 'bio' ), array( __( 'Registered', 'desktop-mode' ), (string) date_i18n( get_option( 'date_format' ), strtotime( $user->user_registered ) ), 'bio' ), ) ), 'stats' => stats_payload( 'openstation_my_wordpress_user_stats_callback', array( 'id' => $id ) ), 'canEdit' => allowed( $os, $section, $id, 'edit' ), 'canDelete' => false, ); } $post = get_post( $id ); if ( ! $post || $post->post_type !== $section['post_type'] ) { return null; } $title = '' !== $post->post_title ? (string) $post->post_title : __( '(no title)', 'desktop-mode' ); if ( 'media' === $section['kind'] ) { $file = get_attached_file( $id ); $meta = (array) wp_get_attachment_metadata( $id ); $used = array(); if ( function_exists( 'openstation_my_wordpress_media_usage_build' ) ) { foreach ( array_slice( (array) ( openstation_my_wordpress_media_usage_build( $post )['usedIn'] ?? array() ), 0, 12 ) as $row ) { $used[] = array( 'title' => (string) ( $row['title'] ?? '' ), 'usedAs' => (string) ( $row['usedAs'] ?? '' ), ); } } return array( 'kind' => 'media', 'id' => $id, 'title' => $title, 'mime' => (string) $post->post_mime_type, 'image' => (string) wp_get_attachment_image_url( $id, 'large' ), 'full' => (string) wp_get_attachment_image_url( $id, 'full' ), 'facts' => Os::facts( array( array( __( 'Type', 'desktop-mode' ), (string) $post->post_mime_type ), array( __( 'Size', 'desktop-mode' ), $file && file_exists( $file ) ? (string) size_format( (int) filesize( $file ) ) : '' ), array( __( 'Dimensions', 'desktop-mode' ), isset( $meta['width'], $meta['height'] ) ? $meta['width'] . ' × ' . $meta['height'] : '', ), array( __( 'Uploaded', 'desktop-mode' ), (string) get_the_date( '', $post ) ), ) ), 'usedIn' => $used, 'canEdit' => allowed( $os, $section, $id, 'edit' ), 'canDelete' => allowed( $os, $section, $id, 'delete' ), ); } // The rendered body — what WP Explorer's preview pane shows. // Server-rendered, admin-trusted, injected verbatim by the client. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying Core's own content pipeline (blocks, shortcodes, embeds), not declaring a hook. $content = apply_filters( 'the_content', (string) $post->post_content ); return array( 'kind' => 'post', 'id' => $id, 'title' => $title, 'image' => (string) get_the_post_thumbnail_url( $post, 'large' ), 'content' => (string) $content, 'lockedBy' => lock_holder( $id ), 'facts' => Os::facts( array( array( __( 'Status', 'desktop-mode' ), ucfirst( (string) $post->post_status ) ), array( __( 'Author', 'desktop-mode' ), (string) get_the_author_meta( 'display_name', (int) $post->post_author ) ), array( __( 'Published', 'desktop-mode' ), (string) get_the_date( '', $post ) ), array( __( 'Modified', 'desktop-mode' ), (string) get_the_modified_date( '', $post ) ), array( __( 'Words', 'desktop-mode' ), number_format_i18n( str_word_count( wp_strip_all_tags( (string) $post->post_content ) ) ) ), ) ), 'canEdit' => allowed( $os, $section, $id, 'edit' ), 'canDelete' => allowed( $os, $section, $id, 'delete' ), ); }