PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
1.1.12 1.1.11 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 All 36 releases
desktop-mode / apps / my-wordpress / parts / actions.php

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

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