| 1 |
<?php |
| 2 |
/** |
| 3 |
* Posts app — the list query, the data envelope and the server |
| 4 |
* actions the Posts and Pages apps share. |
| 5 |
* |
| 6 |
* The list is the same `/wp/v2/posts` (or `/wp/v2/pages`) request the |
| 7 |
* legacy bundle fetched from the browser, run in-process through |
| 8 |
* `openstation_app_rest_page()`: the filterable default args |
| 9 |
* (`_embed`, `_fields`, an optional `post_type`) merged with the |
| 10 |
* state's page / per-page / search / status / sort / author / tag |
| 11 |
* values. Every REST field a plugin registers and every |
| 12 |
* `rest_post_query` filter keeps applying, and the row JSON the |
| 13 |
* client view renders is byte-identical to what the old bundle got. |
| 14 |
* |
| 15 |
* Plain PHP part of `apps/posts/posts.os.php`; `apps/pages/pages.os.php` |
| 16 |
* requires it too and hands in its own defaults, route and noun. |
| 17 |
* |
| 18 |
* @package OpenStation |
| 19 |
*/ |
| 20 |
|
| 21 |
defined( 'ABSPATH' ) || exit; |
| 22 |
|
| 23 |
use OpenStation\App\Os; |
| 24 |
use OpenStation\App\State; |
| 25 |
|
| 26 |
/** |
| 27 |
* Default REST query args the Posts window uses on every list fetch. |
| 28 |
* |
| 29 |
* Filterable so a plugin can flip the post type to a custom CPT (or |
| 30 |
* a list of CPTs) without forking the app. The app merges these |
| 31 |
* under the page/per-page/search/status/sort args it generates per |
| 32 |
* request. |
| 33 |
* |
| 34 |
* @return array |
| 35 |
*/ |
| 36 |
function openstation_posts_window_default_query_args() { |
| 37 |
$args = array( |
| 38 |
// `_embed` pulls author + taxonomy + featured-media side-loads |
| 39 |
// into `_embedded`, so the table can render avatars, term |
| 40 |
// chips, and thumbnails without N extra round-trips per row. |
| 41 |
'_embed' => 'author,wp:term,wp:featuredmedia', |
| 42 |
// `openstation_lock` is the REST field registered by My WordPress' |
| 43 |
// `lock.php` on every public post type — it tells us whether |
| 44 |
// another user is currently editing the row, so the title cell |
| 45 |
// can paint a small lock icon without an extra fetch. |
| 46 |
'_fields' => |
| 47 |
'id,title,status,date,date_gmt,modified,modified_gmt,author,categories,tags,comment_status,excerpt,openstation_lock,_links,_embedded', |
| 48 |
); |
| 49 |
|
| 50 |
/** |
| 51 |
* Filter the default outbound REST query args for the Posts window. |
| 52 |
* |
| 53 |
* Drop in a `'post_type' => 'product'` to point the window at a |
| 54 |
* CPT, or extend `_fields` to ship more columns. The app merges |
| 55 |
* these under pagination/search/status/sort args. |
| 56 |
* |
| 57 |
* @param array $args Default args. |
| 58 |
*/ |
| 59 |
return (array) apply_filters( 'openstation_posts_window_query_args', $args ); |
| 60 |
} |
| 61 |
|
| 62 |
/** |
| 63 |
* The declared state of a list app — the schema the client keeps. |
| 64 |
* |
| 65 |
* @param string $orderby Default sort column (`date` for posts, `menu_order` for pages). |
| 66 |
* @param string $order Default direction. |
| 67 |
* @return array<string,mixed> |
| 68 |
*/ |
| 69 |
function openstation_posts_app_state( $orderby = 'date', $order = 'desc' ) { |
| 70 |
return array( |
| 71 |
'page' => 1, |
| 72 |
'feedAppend' => false, |
| 73 |
'perPage' => 20, |
| 74 |
'search' => '', |
| 75 |
// `''` is the "All" sentinel — sent as `status=any`. |
| 76 |
'status' => '', |
| 77 |
'orderby' => (string) $orderby, |
| 78 |
'order' => (string) $order, |
| 79 |
// Author user ids and tag term ids to filter by; empty = no filter. |
| 80 |
'author' => array(), |
| 81 |
'tag' => array(), |
| 82 |
); |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* The REST `orderby` values a column click may set. Anything else |
| 87 |
* (a plugin column core cannot sort by) falls back to the declared |
| 88 |
* default rather than reaching `WP_Query` as a stray key. |
| 89 |
* |
| 90 |
* @return string[] |
| 91 |
*/ |
| 92 |
function openstation_posts_app_allowed_orderby() { |
| 93 |
return array( 'date', 'title', 'author', 'modified', 'comment_count', 'menu_order' ); |
| 94 |
} |
| 95 |
|
| 96 |
/** |
| 97 |
* The static facts the client view reads through `ctx.extra`: the |
| 98 |
* mode, the editor URLs and the declared default sort — what the |
| 99 |
* client returns to when a column sort is cleared. The Pages app |
| 100 |
* layers its own facts on top in `openstation_pages_app_config()`. |
| 101 |
* |
| 102 |
* @param string $mode `posts` | `pages`. |
| 103 |
* @param string $orderby Default sort column. |
| 104 |
* @param string $order Default direction. |
| 105 |
* @return array<string,mixed> |
| 106 |
*/ |
| 107 |
function openstation_posts_app_config( $mode, $orderby = 'date', $order = 'desc' ) { |
| 108 |
return array( |
| 109 |
'mode' => 'pages' === $mode ? 'pages' : 'posts', |
| 110 |
'editPostUrlBase' => esc_url_raw( admin_url( 'post.php' ) ), |
| 111 |
'newPostUrl' => 'pages' === $mode |
| 112 |
? esc_url_raw( add_query_arg( 'post_type', 'page', admin_url( 'post-new.php' ) ) ) |
| 113 |
: esc_url_raw( admin_url( 'post-new.php' ) ), |
| 114 |
'defaultOrderby' => (string) $orderby, |
| 115 |
'defaultOrder' => 'asc' === $order ? 'asc' : 'desc', |
| 116 |
); |
| 117 |
} |
| 118 |
|
| 119 |
/** |
| 120 |
* Positive integers out of a state list. |
| 121 |
* |
| 122 |
* @param mixed $value State value. |
| 123 |
* @return int[] |
| 124 |
*/ |
| 125 |
function openstation_posts_app_ids( $value ) { |
| 126 |
$out = array(); |
| 127 |
foreach ( (array) $value as $id ) { |
| 128 |
$id = (int) $id; |
| 129 |
if ( $id > 0 ) { |
| 130 |
$out[] = $id; |
| 131 |
} |
| 132 |
} |
| 133 |
return $out; |
| 134 |
} |
| 135 |
|
| 136 |
/** |
| 137 |
* The REST query for a state — the legacy bundle's `fetchPosts()` |
| 138 |
* URL, as query params. |
| 139 |
* |
| 140 |
* @param array<string,mixed> $defaults Filtered default args. |
| 141 |
* @param State $state Declared state. |
| 142 |
* @return array<string,mixed> |
| 143 |
*/ |
| 144 |
function openstation_posts_app_query( array $defaults, State $state ) { |
| 145 |
$query = array(); |
| 146 |
// Merge the PHP-declared defaults first so `_fields`, `_embed`, |
| 147 |
// and a custom `post_type` from the filter all flow through. |
| 148 |
foreach ( $defaults as $key => $value ) { |
| 149 |
if ( is_string( $value ) && '' !== $value ) { |
| 150 |
$query[ (string) $key ] = $value; |
| 151 |
} |
| 152 |
} |
| 153 |
$query['page'] = max( 1, (int) $state->get( 'page' ) ); |
| 154 |
$query['per_page'] = max( 1, (int) $state->get( 'perPage' ) ); |
| 155 |
$search = trim( (string) $state->get( 'search' ) ); |
| 156 |
if ( '' !== $search ) { |
| 157 |
$query['search'] = $search; |
| 158 |
} |
| 159 |
// `status` quirk: omitted, core's handler defaults to `publish` |
| 160 |
// only — drafts / pending / scheduled / private silently vanish |
| 161 |
// from "All". `status=any` makes the All segment mean every |
| 162 |
// status the user can see (trash has its own segment). |
| 163 |
$status = (string) $state->get( 'status' ); |
| 164 |
$query['status'] = '' !== $status ? $status : 'any'; |
| 165 |
$orderby = (string) $state->get( 'orderby' ); |
| 166 |
if ( '' !== $orderby ) { |
| 167 |
$query['orderby'] = $orderby; |
| 168 |
} |
| 169 |
$order = (string) $state->get( 'order' ); |
| 170 |
if ( in_array( $order, array( 'asc', 'desc' ), true ) ) { |
| 171 |
$query['order'] = $order; |
| 172 |
} |
| 173 |
// Both `author` and `tags` are registered as integer arrays — |
| 174 |
// union (OR) semantics: "rows whose author / tag is ANY of these". |
| 175 |
$author = openstation_posts_app_ids( $state->get( 'author' ) ); |
| 176 |
if ( array() !== $author ) { |
| 177 |
$query['author'] = $author; |
| 178 |
} |
| 179 |
$tag = openstation_posts_app_ids( $state->get( 'tag' ) ); |
| 180 |
if ( array() !== $tag ) { |
| 181 |
$query['tags'] = $tag; |
| 182 |
} |
| 183 |
return $query; |
| 184 |
} |
| 185 |
|
| 186 |
/** |
| 187 |
* The client data: a continuation page or a refreshed loaded prefix, |
| 188 |
* with a query identity for rejecting stale responses. Out-of-range |
| 189 |
* continuations recover to page one; failed refreshes retain paging. |
| 190 |
* |
| 191 |
* @param string $route `wp/v2/posts` | `wp/v2/pages`. |
| 192 |
* @param array<string,mixed> $defaults Filtered default args. |
| 193 |
* @param State $state Declared state. |
| 194 |
* @return array<string,mixed> `list`. |
| 195 |
*/ |
| 196 |
function openstation_posts_app_data( $route, array $defaults, State $state ) { |
| 197 |
$query = openstation_posts_app_query( $defaults, $state ); |
| 198 |
$append = (bool) $state->get( 'feedAppend' ); |
| 199 |
$state->set( 'feedAppend', false ); |
| 200 |
// Refresh the loaded prefix in one HTTP response. Core still enforces |
| 201 |
// permissions, query filters and its 100-row bound for each internal batch. |
| 202 |
if ( ! $append && $query['page'] > 1 ) { |
| 203 |
$wanted = $query['page'] * $query['per_page']; |
| 204 |
$refresh = $query; |
| 205 |
$refresh['page'] = 1; |
| 206 |
$refresh['per_page'] = min( 100, $wanted ); |
| 207 |
$list = openstation_app_rest_page( $route, $refresh ); |
| 208 |
$wanted = min( $wanted, $list['total'] ); |
| 209 |
$received = count( $list['items'] ); |
| 210 |
while ( empty( $list['error'] ) && $received < $wanted ) { |
| 211 |
++$refresh['page']; |
| 212 |
$batch = openstation_app_rest_page( $route, $refresh ); |
| 213 |
if ( ! empty( $batch['error'] ) ) { |
| 214 |
$list = $batch; |
| 215 |
break; |
| 216 |
} |
| 217 |
if ( empty( $batch['items'] ) ) { |
| 218 |
break; |
| 219 |
} |
| 220 |
$list['items'] = array_merge( $list['items'], $batch['items'] ); |
| 221 |
$received = count( $list['items'] ); |
| 222 |
} |
| 223 |
$list['items'] = array_slice( $list['items'], 0, $wanted ); |
| 224 |
$list['perPage'] = $query['per_page']; |
| 225 |
$list['pages'] = (int) ceil( $list['total'] / $query['per_page'] ); |
| 226 |
$list['page'] = empty( $list['error'] ) ? min( $query['page'], max( 1, $list['pages'] ) ) : $query['page']; |
| 227 |
$state->set( 'page', $list['page'] ); |
| 228 |
$list['replace'] = true; |
| 229 |
} else { |
| 230 |
$list = openstation_app_rest_page( $route, $query ); |
| 231 |
} |
| 232 |
// A page past the end — Core's `rest_post_invalid_page_number` |
| 233 |
// refusal, or an empty page on a controller that tolerates it — |
| 234 |
// lands on page 1 silently rather than render an empty table. A |
| 235 |
// refusal for any other reason (a capability, a bad argument) is |
| 236 |
// never retried: the error reaches the client as it is. |
| 237 |
if ( openstation_app_rest_page_is_out_of_range( $list ) ) { |
| 238 |
$state->set( 'page', 1 ); |
| 239 |
$query['page'] = 1; |
| 240 |
$list = openstation_app_rest_page( $route, $query ); |
| 241 |
} |
| 242 |
if ( 0 === $list['total'] ) { |
| 243 |
// The legacy pager read "No posts" off a zero page count; the |
| 244 |
// envelope floors `pages` at 1, so hand the client the truth. |
| 245 |
$list['pages'] = 0; |
| 246 |
} |
| 247 |
$identity = array(); |
| 248 |
foreach ( array( 'search', 'status', 'orderby', 'order', 'author', 'tag', 'perPage' ) as $key ) { |
| 249 |
$identity[ $key ] = $state->get( $key ); |
| 250 |
} |
| 251 |
return array( |
| 252 |
'list' => $list, |
| 253 |
'query' => $identity, |
| 254 |
); |
| 255 |
} |
| 256 |
|
| 257 |
/** |
| 258 |
* `filter` — a query change (status, search, per-page, column |
| 259 |
* filters) replaces the result set from page 1. |
| 260 |
* |
| 261 |
* @param State $state Declared state. |
| 262 |
* @return void |
| 263 |
*/ |
| 264 |
function openstation_posts_app_filter( State $state ) { |
| 265 |
$state->set( 'page', 1 ); |
| 266 |
} |
| 267 |
|
| 268 |
/** |
| 269 |
* `page` — move to the requested page. |
| 270 |
* |
| 271 |
* @param State $state Declared state. |
| 272 |
* @param array<string,mixed> $args `page`. |
| 273 |
* @return void |
| 274 |
*/ |
| 275 |
function openstation_posts_app_page( State $state, array $args ) { |
| 276 |
$state->set( 'page', max( 1, isset( $args['page'] ) ? (int) $args['page'] : 1 ) ); |
| 277 |
$state->set( 'feedAppend', true ); |
| 278 |
} |
| 279 |
|
| 280 |
/** |
| 281 |
* `sort` — a column header click; the client maps the column key to |
| 282 |
* the REST `orderby` value, and the server only keeps one it knows: |
| 283 |
* anything outside `openstation_posts_app_allowed_orderby()` is the |
| 284 |
* app's declared default. |
| 285 |
* |
| 286 |
* @param State $state Declared state. |
| 287 |
* @param array<string,mixed> $args `orderby`, `order`. |
| 288 |
* @param string $default_orderby The app's default sort column. |
| 289 |
* @param string $default_order The app's default direction. |
| 290 |
* @return void |
| 291 |
*/ |
| 292 |
function openstation_posts_app_sort( State $state, array $args, $default_orderby = 'date', $default_order = 'desc' ) { |
| 293 |
$orderby = isset( $args['orderby'] ) ? sanitize_key( (string) $args['orderby'] ) : ''; |
| 294 |
if ( ! in_array( $orderby, openstation_posts_app_allowed_orderby(), true ) ) { |
| 295 |
$orderby = (string) $default_orderby; |
| 296 |
} |
| 297 |
$order = isset( $args['order'] ) ? strtolower( (string) $args['order'] ) : (string) $default_order; |
| 298 |
$state->set( 'page', 1 ); |
| 299 |
$state->set( 'orderby', $orderby ); |
| 300 |
$state->set( 'order', 'asc' === $order ? 'asc' : 'desc' ); |
| 301 |
} |
| 302 |
|
| 303 |
/** |
| 304 |
* `trash` — move the selected rows to the trash. Rows already in the |
| 305 |
* trash are skipped (a second delete would remove them for good), |
| 306 |
* every id is checked against `delete_post`, the survivors are |
| 307 |
* announced as one content change (what the Recycle Bin, WP Explorer |
| 308 |
* and every other window showing the type repaint on), and failures |
| 309 |
* become a toast. |
| 310 |
* |
| 311 |
* @param Os $os Host handle. |
| 312 |
* @param array<string,mixed> $args `ids`. |
| 313 |
* @param string $type Content type announced (`post` | `page`). |
| 314 |
* @return void |
| 315 |
*/ |
| 316 |
function openstation_posts_app_trash( Os $os, array $args, $type ) { |
| 317 |
$ids = openstation_posts_app_ids( isset( $args['ids'] ) ? $args['ids'] : array() ); |
| 318 |
$ok = array(); |
| 319 |
$failed = 0; |
| 320 |
foreach ( $ids as $id ) { |
| 321 |
$post = get_post( $id ); |
| 322 |
if ( ! $post || 'trash' === $post->post_status ) { |
| 323 |
continue; |
| 324 |
} |
| 325 |
if ( ! $os->can( 'delete_post', $id ) || ! wp_trash_post( $id ) ) { |
| 326 |
++$failed; |
| 327 |
continue; |
| 328 |
} |
| 329 |
$ok[] = $id; |
| 330 |
} |
| 331 |
if ( array() !== $ok ) { |
| 332 |
$os->announce( $type, 'trashed', $ok ); |
| 333 |
} |
| 334 |
if ( $failed > 0 ) { |
| 335 |
$os->toast( |
| 336 |
sprintf( |
| 337 |
/* translators: %d: number of rows that could not be trashed. */ |
| 338 |
_n( '%d item could not be moved to the trash.', '%d items could not be moved to the trash.', $failed, 'desktop-mode' ), |
| 339 |
$failed |
| 340 |
) |
| 341 |
); |
| 342 |
} |
| 343 |
} |
| 344 |
|