'author,wp:term,wp:featuredmedia', // `openstation_lock` is the REST field registered by My WordPress' // `lock.php` on every public post type — it tells us whether // another user is currently editing the row, so the title cell // can paint a small lock icon without an extra fetch. '_fields' => 'id,title,status,date,date_gmt,modified,modified_gmt,author,categories,tags,comment_status,excerpt,openstation_lock,_links,_embedded', ); /** * Filter the default outbound REST query args for the Posts window. * * Drop in a `'post_type' => 'product'` to point the window at a * CPT, or extend `_fields` to ship more columns. The app merges * these under pagination/search/status/sort args. * * @param array $args Default args. */ return (array) apply_filters( 'openstation_posts_window_query_args', $args ); } /** * The declared state of a list app — the schema the client keeps. * * @param string $orderby Default sort column (`date` for posts, `menu_order` for pages). * @param string $order Default direction. * @return array */ function openstation_posts_app_state( $orderby = 'date', $order = 'desc' ) { return array( 'page' => 1, 'feedAppend' => false, 'perPage' => 20, 'search' => '', // `''` is the "All" sentinel — sent as `status=any`. 'status' => '', 'orderby' => (string) $orderby, 'order' => (string) $order, // Author user ids and tag term ids to filter by; empty = no filter. 'author' => array(), 'tag' => array(), ); } /** * The REST `orderby` values a column click may set. Anything else * (a plugin column core cannot sort by) falls back to the declared * default rather than reaching `WP_Query` as a stray key. * * @return string[] */ function openstation_posts_app_allowed_orderby() { return array( 'date', 'title', 'author', 'modified', 'comment_count', 'menu_order' ); } /** * The static facts the client view reads through `ctx.extra`: the * mode, the editor URLs and the declared default sort — what the * client returns to when a column sort is cleared. The Pages app * layers its own facts on top in `openstation_pages_app_config()`. * * @param string $mode `posts` | `pages`. * @param string $orderby Default sort column. * @param string $order Default direction. * @return array */ function openstation_posts_app_config( $mode, $orderby = 'date', $order = 'desc' ) { return array( 'mode' => 'pages' === $mode ? 'pages' : 'posts', 'editPostUrlBase' => esc_url_raw( admin_url( 'post.php' ) ), 'newPostUrl' => 'pages' === $mode ? esc_url_raw( add_query_arg( 'post_type', 'page', admin_url( 'post-new.php' ) ) ) : esc_url_raw( admin_url( 'post-new.php' ) ), 'defaultOrderby' => (string) $orderby, 'defaultOrder' => 'asc' === $order ? 'asc' : 'desc', ); } /** * Positive integers out of a state list. * * @param mixed $value State value. * @return int[] */ function openstation_posts_app_ids( $value ) { $out = array(); foreach ( (array) $value as $id ) { $id = (int) $id; if ( $id > 0 ) { $out[] = $id; } } return $out; } /** * The REST query for a state — the legacy bundle's `fetchPosts()` * URL, as query params. * * @param array $defaults Filtered default args. * @param State $state Declared state. * @return array */ function openstation_posts_app_query( array $defaults, State $state ) { $query = array(); // Merge the PHP-declared defaults first so `_fields`, `_embed`, // and a custom `post_type` from the filter all flow through. foreach ( $defaults as $key => $value ) { if ( is_string( $value ) && '' !== $value ) { $query[ (string) $key ] = $value; } } $query['page'] = max( 1, (int) $state->get( 'page' ) ); $query['per_page'] = max( 1, (int) $state->get( 'perPage' ) ); $search = trim( (string) $state->get( 'search' ) ); if ( '' !== $search ) { $query['search'] = $search; } // `status` quirk: omitted, core's handler defaults to `publish` // only — drafts / pending / scheduled / private silently vanish // from "All". `status=any` makes the All segment mean every // status the user can see (trash has its own segment). $status = (string) $state->get( 'status' ); $query['status'] = '' !== $status ? $status : 'any'; $orderby = (string) $state->get( 'orderby' ); if ( '' !== $orderby ) { $query['orderby'] = $orderby; } $order = (string) $state->get( 'order' ); if ( in_array( $order, array( 'asc', 'desc' ), true ) ) { $query['order'] = $order; } // Both `author` and `tags` are registered as integer arrays — // union (OR) semantics: "rows whose author / tag is ANY of these". $author = openstation_posts_app_ids( $state->get( 'author' ) ); if ( array() !== $author ) { $query['author'] = $author; } $tag = openstation_posts_app_ids( $state->get( 'tag' ) ); if ( array() !== $tag ) { $query['tags'] = $tag; } return $query; } /** * The client data: a continuation page or a refreshed loaded prefix, * with a query identity for rejecting stale responses. Out-of-range * continuations recover to page one; failed refreshes retain paging. * * @param string $route `wp/v2/posts` | `wp/v2/pages`. * @param array $defaults Filtered default args. * @param State $state Declared state. * @return array `list`. */ function openstation_posts_app_data( $route, array $defaults, State $state ) { $query = openstation_posts_app_query( $defaults, $state ); $append = (bool) $state->get( 'feedAppend' ); $state->set( 'feedAppend', false ); // Refresh the loaded prefix in one HTTP response. Core still enforces // permissions, query filters and its 100-row bound for each internal batch. if ( ! $append && $query['page'] > 1 ) { $wanted = $query['page'] * $query['per_page']; $refresh = $query; $refresh['page'] = 1; $refresh['per_page'] = min( 100, $wanted ); $list = openstation_app_rest_page( $route, $refresh ); $wanted = min( $wanted, $list['total'] ); $received = count( $list['items'] ); while ( empty( $list['error'] ) && $received < $wanted ) { ++$refresh['page']; $batch = openstation_app_rest_page( $route, $refresh ); if ( ! empty( $batch['error'] ) ) { $list = $batch; break; } if ( empty( $batch['items'] ) ) { break; } $list['items'] = array_merge( $list['items'], $batch['items'] ); $received = count( $list['items'] ); } $list['items'] = array_slice( $list['items'], 0, $wanted ); $list['perPage'] = $query['per_page']; $list['pages'] = (int) ceil( $list['total'] / $query['per_page'] ); $list['page'] = empty( $list['error'] ) ? min( $query['page'], max( 1, $list['pages'] ) ) : $query['page']; $state->set( 'page', $list['page'] ); $list['replace'] = true; } else { $list = openstation_app_rest_page( $route, $query ); } // A page past the end — Core's `rest_post_invalid_page_number` // refusal, or an empty page on a controller that tolerates it — // lands on page 1 silently rather than render an empty table. A // refusal for any other reason (a capability, a bad argument) is // never retried: the error reaches the client as it is. if ( openstation_app_rest_page_is_out_of_range( $list ) ) { $state->set( 'page', 1 ); $query['page'] = 1; $list = openstation_app_rest_page( $route, $query ); } if ( 0 === $list['total'] ) { // The legacy pager read "No posts" off a zero page count; the // envelope floors `pages` at 1, so hand the client the truth. $list['pages'] = 0; } $identity = array(); foreach ( array( 'search', 'status', 'orderby', 'order', 'author', 'tag', 'perPage' ) as $key ) { $identity[ $key ] = $state->get( $key ); } return array( 'list' => $list, 'query' => $identity, ); } /** * `filter` — a query change (status, search, per-page, column * filters) replaces the result set from page 1. * * @param State $state Declared state. * @return void */ function openstation_posts_app_filter( State $state ) { $state->set( 'page', 1 ); } /** * `page` — move to the requested page. * * @param State $state Declared state. * @param array $args `page`. * @return void */ function openstation_posts_app_page( State $state, array $args ) { $state->set( 'page', max( 1, isset( $args['page'] ) ? (int) $args['page'] : 1 ) ); $state->set( 'feedAppend', true ); } /** * `sort` — a column header click; the client maps the column key to * the REST `orderby` value, and the server only keeps one it knows: * anything outside `openstation_posts_app_allowed_orderby()` is the * app's declared default. * * @param State $state Declared state. * @param array $args `orderby`, `order`. * @param string $default_orderby The app's default sort column. * @param string $default_order The app's default direction. * @return void */ function openstation_posts_app_sort( State $state, array $args, $default_orderby = 'date', $default_order = 'desc' ) { $orderby = isset( $args['orderby'] ) ? sanitize_key( (string) $args['orderby'] ) : ''; if ( ! in_array( $orderby, openstation_posts_app_allowed_orderby(), true ) ) { $orderby = (string) $default_orderby; } $order = isset( $args['order'] ) ? strtolower( (string) $args['order'] ) : (string) $default_order; $state->set( 'page', 1 ); $state->set( 'orderby', $orderby ); $state->set( 'order', 'asc' === $order ? 'asc' : 'desc' ); } /** * `trash` — move the selected rows to the trash. Rows already in the * trash are skipped (a second delete would remove them for good), * every id is checked against `delete_post`, the survivors are * announced as one content change (what the Recycle Bin, WP Explorer * and every other window showing the type repaint on), and failures * become a toast. * * @param Os $os Host handle. * @param array $args `ids`. * @param string $type Content type announced (`post` | `page`). * @return void */ function openstation_posts_app_trash( Os $os, array $args, $type ) { $ids = openstation_posts_app_ids( isset( $args['ids'] ) ? $args['ids'] : array() ); $ok = array(); $failed = 0; foreach ( $ids as $id ) { $post = get_post( $id ); if ( ! $post || 'trash' === $post->post_status ) { continue; } if ( ! $os->can( 'delete_post', $id ) || ! wp_trash_post( $id ) ) { ++$failed; continue; } $ok[] = $id; } if ( array() !== $ok ) { $os->announce( $type, 'trashed', $ok ); } if ( $failed > 0 ) { $os->toast( sprintf( /* translators: %d: number of rows that could not be trashed. */ _n( '%d item could not be moved to the trash.', '%d items could not be moved to the trash.', $failed, 'desktop-mode' ), $failed ) ); } }