PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.7
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 0.8.8 0.8.7 All 34 releases
desktop-mode / apps / my-wordpress / parts / actions.php

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

474 lines 15.6 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
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