| 1 |
<?php |
| 2 |
/** |
| 3 |
* My WordPress — the content actions. |
| 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 everything a |
| 8 |
* dispatch DOES to the content surface: navigation (go / back / into / |
| 9 |
* relation), list controls (search / sort / paging), and the mutations |
| 10 |
* (open-in-editor, trash, bulk trash, quick edit) with their per-item |
| 11 |
* authorization. The agent actions live in `parts/agents.php`. |
| 12 |
* |
| 13 |
* @package OpenStation |
| 14 |
*/ |
| 15 |
|
| 16 |
namespace OpenStation\Apps\MyWordPress; |
| 17 |
|
| 18 |
use OpenStation\App\Os; |
| 19 |
use OpenStation\App\State; |
| 20 |
|
| 21 |
// Direct access, unless a standalone host is booting on bare PHP. |
| 22 |
if ( ! defined( 'ABSPATH' ) ) { |
| 23 |
defined( 'OPENSTATION_STANDALONE' ) || exit; |
| 24 |
} |
| 25 |
|
| 26 |
/** The two ways a section lists. */ |
| 27 |
const VIEWS = array( 'icons', 'list' ); |
| 28 |
|
| 29 |
/** |
| 30 |
* The storage key of the per-section hidden-column map. |
| 31 |
*/ |
| 32 |
const COLUMNS_KEY = 'hidden-columns'; |
| 33 |
|
| 34 |
/** |
| 35 |
* Mount: restore the remembered view mode. The client's first |
| 36 |
* dispatch carries the schema default; the stored preference wins. |
| 37 |
* |
| 38 |
* @param State $state State. |
| 39 |
* @param Os $os Host handle. |
| 40 |
* @return void |
| 41 |
*/ |
| 42 |
function mount( State $state, Os $os ) { |
| 43 |
$stored = (string) $os->stored( 'view', 'icons' ); |
| 44 |
$state->set( 'view', in_array( $stored, VIEWS, true ) ? $stored : 'icons' ); |
| 45 |
} |
| 46 |
|
| 47 |
/** |
| 48 |
* `view`: the mode switch. The bound value already arrived with the |
| 49 |
* state (the client flips locally first, so the switch is instant); |
| 50 |
* this validates it and remembers it for the next window. |
| 51 |
* |
| 52 |
* @param State $state State. |
| 53 |
* @param Os $os Host handle. |
| 54 |
* @return void |
| 55 |
*/ |
| 56 |
function view_action( State $state, Os $os ) { |
| 57 |
$view = (string) $state->get( 'view' ); |
| 58 |
if ( ! in_array( $view, VIEWS, true ) ) { |
| 59 |
$view = 'icons'; |
| 60 |
$state->set( 'view', $view ); |
| 61 |
} |
| 62 |
$os->store( 'view', $view ); |
| 63 |
} |
| 64 |
|
| 65 |
/** |
| 66 |
* The per-section map of hidden list columns, as stored: section id |
| 67 |
* → column ids. Columns are declared client-side (plugins add their |
| 68 |
* own through the `os.my-wordpress.list-columns` filter), so the |
| 69 |
* server keeps the user's choice as opaque, sanitised keys. |
| 70 |
* |
| 71 |
* @param Os $os Host handle. |
| 72 |
* @return array<string,string[]> |
| 73 |
*/ |
| 74 |
function hidden_columns( Os $os ) { |
| 75 |
$stored = $os->stored( COLUMNS_KEY, array() ); |
| 76 |
$map = array(); |
| 77 |
foreach ( is_array( $stored ) ? $stored : array() as $section => $ids ) { |
| 78 |
$section = sanitize_key( (string) $section ); |
| 79 |
if ( '' === $section || ! is_array( $ids ) ) { |
| 80 |
continue; |
| 81 |
} |
| 82 |
$map[ $section ] = array_values( array_unique( array_filter( array_map( 'sanitize_key', array_map( 'strval', $ids ) ) ) ) ); |
| 83 |
} |
| 84 |
return $map; |
| 85 |
} |
| 86 |
|
| 87 |
/** |
| 88 |
* `set-columns`: remember which columns the current section hides. |
| 89 |
* The client sends the whole hidden list (`hidden`), because only it |
| 90 |
* knows the default set — a column a plugin added last week is not |
| 91 |
* something the server can name. An empty list is a choice too |
| 92 |
* ("show everything"); `reset` is what forgets the section. |
| 93 |
* |
| 94 |
* @param State $state State. |
| 95 |
* @param Os $os Host handle. |
| 96 |
* @param array<string,mixed> $args Trigger args (`hidden` | `reset`). |
| 97 |
* @return void |
| 98 |
*/ |
| 99 |
function set_columns_action( State $state, Os $os, array $args ) { |
| 100 |
$section = sanitize_key( (string) $state->get( 'section' ) ); |
| 101 |
if ( '' === $section ) { |
| 102 |
return; |
| 103 |
} |
| 104 |
$map = hidden_columns( $os ); |
| 105 |
if ( ! empty( $args['reset'] ) ) { |
| 106 |
unset( $map[ $section ] ); |
| 107 |
} else { |
| 108 |
$map[ $section ] = array_values( |
| 109 |
array_unique( |
| 110 |
array_filter( |
| 111 |
array_map( 'sanitize_key', array_map( 'strval', array_slice( (array) ( $args['hidden'] ?? array() ), 0, 40 ) ) ), |
| 112 |
'strlen' |
| 113 |
) |
| 114 |
) |
| 115 |
); |
| 116 |
} |
| 117 |
// A bounded map: the sections a person actually tuned, never a |
| 118 |
// growing log of every folder they ever opened. |
| 119 |
$map = array_slice( $map, -40, 40, true ); |
| 120 |
if ( array() === $map ) { |
| 121 |
$os->forget( COLUMNS_KEY ); |
| 122 |
} else { |
| 123 |
$os->store( COLUMNS_KEY, $map ); |
| 124 |
} |
| 125 |
} |
| 126 |
|
| 127 |
/** |
| 128 |
* `go`: open a root folder or a section, resetting the whole trail. |
| 129 |
* |
| 130 |
* @param State $state State. |
| 131 |
* @param Os $os Host handle. |
| 132 |
* @param array<string,mixed> $args Trigger args. |
| 133 |
* @return void |
| 134 |
*/ |
| 135 |
function go_action( State $state, Os $os, array $args ) { |
| 136 |
$state->set( 'group', isset( $args['group'] ) ? (string) $args['group'] : '' ); |
| 137 |
$state->set( 'section', isset( $args['section'] ) ? (string) $args['section'] : '' ); |
| 138 |
$state->set( 'item', 0 )->set( 'into', 0 )->set( 'relation', '' ) |
| 139 |
->set( 'footprint', 0 )->set( 'fpName', '' ) |
| 140 |
->set( 'query', '' )->set( 'page', 1 ) |
| 141 |
->set( 'sort', '' )->reset( 'selected' ) |
| 142 |
->set( 'pane', 'define' )->set( 'casting', false )->set( 'wstep', 0 ) |
| 143 |
->reset( 'cast' )->set( 'agentNotice', '' )->set( 'briefError', '' ); |
| 144 |
} |
| 145 |
|
| 146 |
/** |
| 147 |
* `back`: one step up — wizard, relation, folder, pane, section, group. |
| 148 |
* |
| 149 |
* @param State $state State. |
| 150 |
* @return void |
| 151 |
*/ |
| 152 |
function back_action( State $state ) { |
| 153 |
if ( true === $state->get( 'casting' ) ) { |
| 154 |
// The window's back is the wizard's cancel. |
| 155 |
$state->set( 'casting', false )->set( 'wstep', 0 ) |
| 156 |
->reset( 'cast' )->set( 'agentNotice', '' )->set( 'briefError', '' ); |
| 157 |
return; |
| 158 |
} |
| 159 |
if ( (int) $state->get( 'footprint' ) > 0 ) { |
| 160 |
$state->set( 'footprint', 0 )->set( 'fpName', '' ); |
| 161 |
return; |
| 162 |
} |
| 163 |
if ( '' !== (string) $state->get( 'relation' ) ) { |
| 164 |
$state->set( 'relation', '' ); |
| 165 |
return; |
| 166 |
} |
| 167 |
if ( (int) $state->get( 'into' ) > 0 ) { |
| 168 |
$state->set( 'into', 0 ); |
| 169 |
return; |
| 170 |
} |
| 171 |
if ( (int) $state->get( 'item' ) > 0 ) { |
| 172 |
$state->set( 'item', 0 )->set( 'pane', 'define' )->set( 'agentNotice', '' ); |
| 173 |
return; |
| 174 |
} |
| 175 |
if ( '' !== (string) $state->get( 'section' ) ) { |
| 176 |
$state->set( 'section', '' )->set( 'query', '' )->set( 'page', 1 ) |
| 177 |
->set( 'sort', '' )->reset( 'selected' ); |
| 178 |
return; |
| 179 |
} |
| 180 |
$state->set( 'group', '' ); |
| 181 |
} |
| 182 |
|
| 183 |
/** |
| 184 |
* `open`: select an item into the detail pane (0 closes it). |
| 185 |
* |
| 186 |
* @param State $state State. |
| 187 |
* @param Os $os Host handle. |
| 188 |
* @param array<string,mixed> $args Trigger args. |
| 189 |
* @return void |
| 190 |
*/ |
| 191 |
function open_action( State $state, Os $os, array $args ) { |
| 192 |
$state->set( 'item', (int) ( $args['item'] ?? 0 ) ) |
| 193 |
->set( 'pane', 'define' )->set( 'agentNotice', '' ); |
| 194 |
} |
| 195 |
|
| 196 |
/** |
| 197 |
* `into`: navigate INTO a post's detail folder. |
| 198 |
* |
| 199 |
* @param State $state State. |
| 200 |
* @param Os $os Host handle. |
| 201 |
* @param array<string,mixed> $args Trigger args. |
| 202 |
* @return void |
| 203 |
*/ |
| 204 |
function into_action( State $state, Os $os, array $args ) { |
| 205 |
$state->set( 'into', (int) ( $args['item'] ?? 0 ) ) |
| 206 |
->set( 'relation', '' )->set( 'item', 0 ); |
| 207 |
} |
| 208 |
|
| 209 |
/** |
| 210 |
* `relation`: open one relation sub-folder inside the detail folder. |
| 211 |
* |
| 212 |
* @param State $state State. |
| 213 |
* @param Os $os Host handle. |
| 214 |
* @param array<string,mixed> $args Trigger args. |
| 215 |
* @return void |
| 216 |
*/ |
| 217 |
function relation_action( State $state, Os $os, array $args ) { |
| 218 |
$relation = (string) ( $args['relation'] ?? '' ); |
| 219 |
$allowed = array( 'author', 'contributors', 'comments', 'categories', 'tags', 'media', 'revisions' ); |
| 220 |
$state->set( 'relation', in_array( $relation, $allowed, true ) ? $relation : '' ) |
| 221 |
->set( 'item', 0 ); |
| 222 |
} |
| 223 |
|
| 224 |
/** |
| 225 |
* `footprint`: open a user's activity footprint over the body — the |
| 226 |
* full-width surface WP Explorer answered "open this person" with. |
| 227 |
* The id is validated (the payload route re-checks the viewer), the |
| 228 |
* name is only ever a breadcrumb placeholder. |
| 229 |
* |
| 230 |
* @param State $state State. |
| 231 |
* @param Os $os Host handle. |
| 232 |
* @param array<string,mixed> $args Trigger args (`user`, `name`). |
| 233 |
* @return void |
| 234 |
*/ |
| 235 |
function footprint_action( State $state, Os $os, array $args ) { |
| 236 |
$user = (int) ( $args['user'] ?? 0 ); |
| 237 |
if ( $user <= 0 || false === get_userdata( $user ) ) { |
| 238 |
return; |
| 239 |
} |
| 240 |
$state->set( 'footprint', $user ) |
| 241 |
->set( 'fpName', sanitize_text_field( (string) ( $args['name'] ?? '' ) ) ) |
| 242 |
->set( 'item', 0 )->set( 'into', 0 )->set( 'relation', '' ); |
| 243 |
} |
| 244 |
|
| 245 |
/** |
| 246 |
* `sub-open-post`: a recent-posts row in a stats pane → its editor. |
| 247 |
* |
| 248 |
* @param State $state State. |
| 249 |
* @param Os $os Host handle. |
| 250 |
* @param array<string,mixed> $args Trigger args. |
| 251 |
* @return void |
| 252 |
*/ |
| 253 |
function sub_open_post_action( State $state, Os $os, array $args ) { |
| 254 |
$id = (int) ( $args['post'] ?? 0 ); |
| 255 |
if ( $id > 0 && $os->can( 'edit_post', $id ) ) { |
| 256 |
$os->open_url( |
| 257 |
admin_url( 'post.php?post=' . $id . '&action=edit' ), |
| 258 |
edit_title( array( 'kind' => 'post' ), $id ) |
| 259 |
); |
| 260 |
} |
| 261 |
} |
| 262 |
|
| 263 |
/** |
| 264 |
* `edit`: open one item's editor, re-checking the capability here. |
| 265 |
* |
| 266 |
* @param State $state State. |
| 267 |
* @param Os $os Host handle. |
| 268 |
* @param array<string,mixed> $args Trigger args. |
| 269 |
* @return void |
| 270 |
*/ |
| 271 |
function edit_action( State $state, Os $os, array $args ) { |
| 272 |
$section = section_of( $os, (string) $state->get( 'section' ) ); |
| 273 |
$id = (int) ( $args['item'] ?? 0 ); |
| 274 |
if ( $section && allowed( $os, $section, $id, 'edit' ) ) { |
| 275 |
$os->open_url( edit_url( $section, $id ), edit_title( $section, $id ), (string) ( $section['icon'] ?? '' ) ); |
| 276 |
} |
| 277 |
} |
| 278 |
|
| 279 |
/** |
| 280 |
* `add-user`: open Core's Add User screen as a window. Deliberately |
| 281 |
* Core's own screen rather than a bespoke form: on multisite it |
| 282 |
* carries the whole invite flow — Add Existing User, confirmation |
| 283 |
* emails, the network's Add Users setting — which subsite admins |
| 284 |
* otherwise lose entirely. Gated the way Core gates the screen's |
| 285 |
* menu entry. |
| 286 |
* |
| 287 |
* @param State $state State. |
| 288 |
* @param Os $os Host handle. |
| 289 |
* @return void |
| 290 |
*/ |
| 291 |
function add_user_action( State $state, Os $os ) { |
| 292 |
unset( $state ); |
| 293 |
if ( $os->can( 'create_users' ) || ( is_multisite() && $os->can( 'promote_users' ) ) ) { |
| 294 |
$os->open_url( admin_url( 'user-new.php' ), __( 'Add User', 'desktop-mode' ), 'dashicons-admin-users' ); |
| 295 |
} |
| 296 |
} |
| 297 |
|
| 298 |
/** |
| 299 |
* `trash`: move one post to the Trash. |
| 300 |
* |
| 301 |
* @param State $state State. |
| 302 |
* @param Os $os Host handle. |
| 303 |
* @param array<string,mixed> $args Trigger args. |
| 304 |
* @return void |
| 305 |
*/ |
| 306 |
function trash_action( State $state, Os $os, array $args ) { |
| 307 |
$section = section_of( $os, (string) $state->get( 'section' ) ); |
| 308 |
$id = (int) ( $args['item'] ?? 0 ); |
| 309 |
if ( ! $section || 'post' !== $section['kind'] || ! empty( $section['flat'] ) |
| 310 |
|| ! allowed( $os, $section, $id, 'delete' ) ) { |
| 311 |
$os->toast( __( 'You cannot trash this item.', 'desktop-mode' ) ); |
| 312 |
return; |
| 313 |
} |
| 314 |
if ( ! wp_trash_post( $id ) ) { |
| 315 |
$os->toast( __( 'Trashing failed.', 'desktop-mode' ) ); |
| 316 |
return; |
| 317 |
} |
| 318 |
if ( $id === (int) $state->get( 'item' ) ) { |
| 319 |
$state->set( 'item', 0 ); |
| 320 |
} |
| 321 |
if ( $state->contains( 'selected', $id ) ) { |
| 322 |
$state->toggle_item( 'selected', $id ); |
| 323 |
} |
| 324 |
$os->toast( __( 'Moved to the Trash.', 'desktop-mode' ) ); |
| 325 |
$os->announce( (string) $section['post_type'], 'trashed', $id ); |
| 326 |
} |
| 327 |
|
| 328 |
/** |
| 329 |
* `sub-open`: open a sub-list row's editor. The URL is recomputed |
| 330 |
* here from the row id — never taken from the client — so it carries |
| 331 |
* the same capability gates the sub-list applied. |
| 332 |
* |
| 333 |
* @param State $state State. |
| 334 |
* @param Os $os Host handle. |
| 335 |
* @param array<string,mixed> $args Trigger args. |
| 336 |
* @return void |
| 337 |
*/ |
| 338 |
function sub_open_action( State $state, Os $os, array $args ) { |
| 339 |
$section = section_of( $os, (string) $state->get( 'section' ) ); |
| 340 |
$into = (int) $state->get( 'into' ); |
| 341 |
$rel = (string) $state->get( 'relation' ); |
| 342 |
if ( ! $section || $into <= 0 || '' === $rel ) { |
| 343 |
return; |
| 344 |
} |
| 345 |
$payload = sub( $os, $section, $into, $rel ); |
| 346 |
$wanted = (int) ( $args['row'] ?? 0 ); |
| 347 |
foreach ( (array) ( $payload['rows'] ?? array() ) as $row ) { |
| 348 |
if ( $wanted === (int) $row['id'] && '' !== $row['editUrl'] ) { |
| 349 |
$os->open_url( (string) $row['editUrl'], (string) $row['title'] ); |
| 350 |
return; |
| 351 |
} |
| 352 |
} |
| 353 |
} |
| 354 |
|
| 355 |
/** |
| 356 |
* `quick-edit`: apply the Edit… modal's picks over the selection. |
| 357 |
* |
| 358 |
* @param State $state State. |
| 359 |
* @param Os $os Host handle. |
| 360 |
* @param array<string,mixed> $args Trigger args. |
| 361 |
* @return void |
| 362 |
*/ |
| 363 |
function quick_edit_action( State $state, Os $os, array $args ) { |
| 364 |
$section = section_of( $os, (string) $state->get( 'section' ) ); |
| 365 |
// A `flat` section's rows are not posts (an order id may collide |
| 366 |
// with a real post id under legacy storage) — never mutate them. |
| 367 |
if ( ! $section || 'post' !== $section['kind'] || ! empty( $section['flat'] ) ) { |
| 368 |
return; |
| 369 |
} |
| 370 |
$status = isset( $args['status'] ) ? (string) $args['status'] : ''; |
| 371 |
$comments = isset( $args['comments'] ) ? (string) $args['comments'] : ''; |
| 372 |
$author = (int) ( $args['author'] ?? 0 ); |
| 373 |
$sticky = isset( $args['sticky'] ) ? (string) $args['sticky'] : ''; |
| 374 |
$add_cats = array_filter( array_map( 'intval', (array) ( $args['categories'] ?? array() ) ) ); |
| 375 |
$add_tags = array_filter( array_map( 'trim', explode( ',', (string) ( $args['tags'] ?? '' ) ) ) ); |
| 376 |
if ( ! in_array( $status, array( '', 'publish', 'pending', 'draft', 'private' ), true ) |
| 377 |
|| ! in_array( $comments, array( '', 'open', 'closed' ), true ) |
| 378 |
|| ! in_array( $sticky, array( '', 'sticky', 'not-sticky' ), true ) ) { |
| 379 |
return; |
| 380 |
} |
| 381 |
if ( '' === $status && '' === $comments && 0 === $author && '' === $sticky |
| 382 |
&& array() === $add_cats && array() === $add_tags ) { |
| 383 |
return; |
| 384 |
} |
| 385 |
$updated = array(); |
| 386 |
foreach ( array_map( 'intval', (array) ( $args['items'] ?? array() ) ) as $id ) { |
| 387 |
$post = $id > 0 ? get_post( $id ) : null; |
| 388 |
if ( ! $post || $post->post_type !== $section['post_type'] || ! allowed( $os, $section, $id, 'edit' ) ) { |
| 389 |
continue; |
| 390 |
} |
| 391 |
if ( 'publish' === $status && ! $os->can( 'publish_post', $id ) ) { |
| 392 |
continue; |
| 393 |
} |
| 394 |
if ( $author > 0 && ! $os->can( 'edit_others_posts' ) ) { |
| 395 |
continue; |
| 396 |
} |
| 397 |
$fields = array( 'ID' => $id ); |
| 398 |
if ( '' !== $status ) { |
| 399 |
$fields['post_status'] = $status; |
| 400 |
} |
| 401 |
if ( '' !== $comments ) { |
| 402 |
$fields['comment_status'] = $comments; |
| 403 |
} |
| 404 |
if ( $author > 0 && false !== get_userdata( $author ) ) { |
| 405 |
$fields['post_author'] = $author; |
| 406 |
} |
| 407 |
if ( ! wp_update_post( $fields ) ) { |
| 408 |
continue; |
| 409 |
} |
| 410 |
if ( 'post' === $post->post_type ) { |
| 411 |
if ( 'sticky' === $sticky ) { |
| 412 |
stick_post( $id ); |
| 413 |
} elseif ( 'not-sticky' === $sticky ) { |
| 414 |
unstick_post( $id ); |
| 415 |
} |
| 416 |
if ( array() !== $add_cats && is_object_in_taxonomy( $post->post_type, 'category' ) ) { |
| 417 |
wp_set_post_categories( $id, $add_cats, true ); |
| 418 |
} |
| 419 |
if ( array() !== $add_tags && is_object_in_taxonomy( $post->post_type, 'post_tag' ) ) { |
| 420 |
wp_set_post_terms( $id, $add_tags, 'post_tag', true ); |
| 421 |
} |
| 422 |
} |
| 423 |
$updated[] = $id; |
| 424 |
} |
| 425 |
if ( array() === $updated ) { |
| 426 |
$os->toast( __( 'Nothing could be updated.', 'desktop-mode' ) ); |
| 427 |
return; |
| 428 |
} |
| 429 |
$os->toast( |
| 430 |
sprintf( |
| 431 |
/* translators: %s: updated count. */ |
| 432 |
_n( '%s entry updated.', '%s entries updated.', count( $updated ), 'desktop-mode' ), |
| 433 |
number_format_i18n( count( $updated ) ) |
| 434 |
) |
| 435 |
); |
| 436 |
$os->announce( (string) $section['post_type'], 'updated', $updated ); |
| 437 |
} |
| 438 |
|
| 439 |
/** |
| 440 |
* `bulk-trash`: trash the selection, item by item, capability-gated. |
| 441 |
* |
| 442 |
* @param State $state State. |
| 443 |
* @param Os $os Host handle. |
| 444 |
* @return void |
| 445 |
*/ |
| 446 |
function bulk_trash_action( State $state, Os $os ) { |
| 447 |
$section = section_of( $os, (string) $state->get( 'section' ) ); |
| 448 |
if ( ! $section || 'post' !== $section['kind'] || ! empty( $section['flat'] ) ) { |
| 449 |
return; |
| 450 |
} |
| 451 |
$trashed = array(); |
| 452 |
foreach ( array_map( 'intval', (array) $state->get( 'selected' ) ) as $id ) { |
| 453 |
if ( $id > 0 && allowed( $os, $section, $id, 'delete' ) && wp_trash_post( $id ) ) { |
| 454 |
$trashed[] = $id; |
| 455 |
} |
| 456 |
} |
| 457 |
$state->reset( 'selected' ); |
| 458 |
if ( in_array( (int) $state->get( 'item' ), $trashed, true ) ) { |
| 459 |
$state->set( 'item', 0 ); |
| 460 |
} |
| 461 |
if ( array() === $trashed ) { |
| 462 |
$os->toast( __( 'Nothing could be trashed.', 'desktop-mode' ) ); |
| 463 |
return; |
| 464 |
} |
| 465 |
$os->toast( |
| 466 |
sprintf( |
| 467 |
/* translators: %s: trashed count. */ |
| 468 |
_n( 'Moved %s item to the Trash.', 'Moved %s items to the Trash.', count( $trashed ), 'desktop-mode' ), |
| 469 |
number_format_i18n( count( $trashed ) ) |
| 470 |
) |
| 471 |
); |
| 472 |
$os->announce( (string) $section['post_type'], 'trashed', $trashed ); |
| 473 |
} |
| 474 |
|