PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.3
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.3
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 / includes / recycle-bin / store.php

store.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.3, at includes/recycle-bin/store.php

969 lines 32.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Recycle Bin: store.
4 *
5 * Read/restore/purge primitives that the REST layer wraps. Backed
6 * entirely by core post-table state — no custom tables, no options
7 * blob. "Trashed" items are exactly the rows with
8 * `post_status = 'trash'` for the post types the bin tracks.
9 *
10 * Every read goes through `openstation_recycle_bin_query_args` so
11 * plugins can scope the bin (e.g. show only the current user's
12 * trash, or filter by author/role for compliance use cases).
13 *
14 * @package OpenStation
15 */
16
17 defined( 'ABSPATH' ) || exit;
18
19 /**
20 * Returns the list of trashed items the current user is allowed to
21 * see, shaped for the table component.
22 *
23 * @param array $args {
24 * Optional. Query overrides.
25 *
26 * @type int $per_page Default 100.
27 * @type int $page Default 1.
28 * @type string $type One of '', 'post', 'page', 'attachment'.
29 * @type string $search Free-text search over post_title.
30 * }
31 * @return array {
32 * @type array $items List of items shaped for the JS layer.
33 * @type int $total Total matching rows (across pages).
34 * }
35 */
36 function openstation_recycle_bin_get_items( $args = array() ) {
37 $args = wp_parse_args(
38 $args,
39 array(
40 'per_page' => 100,
41 'page' => 1,
42 'type' => '',
43 'search' => '',
44 )
45 );
46
47 $type = (string) $args['type'];
48 $per_page = max( 1, (int) $args['per_page'] );
49
50 // Two trash sources: posts (incl. pages and attachments) and
51 // comments. Each is fetched independently then merged + sorted
52 // by deleted-at desc — that way the bin reads as one chronological
53 // timeline regardless of which entity was trashed.
54 $items_posts = array();
55 $items_comments = array();
56
57 // Source gates: each `$type` filter narrows down to the
58 // owning store. `''` (All) loads every source. The files-on-
59 // desktop sources (`shortcut` / `placement` / `folder`) live in
60 // `openstation_files_list_trashed_for_recycle_bin` — never run
61 // the WP-core post / comment queries when one of those is the
62 // active filter, otherwise trashed posts leak into the
63 // "Shortcuts" / "Folders" tabs.
64 $files_types = array( 'desktop', 'placement', 'shortcut', 'folder' );
65 $is_files_filter = in_array( $type, $files_types, true );
66 $wants_post_types = '' === $type
67 || ( 'comment' !== $type && ! $is_files_filter );
68 $wants_comments = ( '' === $type || 'comment' === $type )
69 && openstation_recycle_bin_comments_enabled();
70
71 if ( $wants_post_types ) {
72 $post_types = openstation_recycle_bin_capture_post_types();
73 if ( '' !== $type && in_array( $type, $post_types, true ) ) {
74 $post_types = array( $type );
75 }
76
77 $query_args = array(
78 'post_type' => $post_types,
79 'post_status' => 'trash',
80 'posts_per_page' => $per_page,
81 'paged' => max( 1, (int) $args['page'] ),
82 'orderby' => 'modified',
83 'order' => 'DESC',
84 'suppress_filters' => false,
85 's' => (string) $args['search'],
86 );
87
88 /**
89 * Filter the WP_Query args used to populate the recycle bin.
90 *
91 * @param array $query_args Args passed to WP_Query.
92 * @param array $args Caller-provided args.
93 */
94 $query_args = apply_filters( 'openstation_recycle_bin_query_args', $query_args, $args );
95
96 $query = new WP_Query( $query_args );
97 foreach ( $query->posts as $post ) {
98 if ( ! openstation_recycle_bin_user_can_view( $post ) ) {
99 continue;
100 }
101 $items_posts[] = openstation_recycle_bin_shape_item( $post );
102 }
103 }
104
105 if ( $wants_comments ) {
106 $comment_args = array(
107 'status' => 'trash',
108 'number' => $per_page,
109 'orderby' => 'comment_date_gmt',
110 'order' => 'DESC',
111 );
112 if ( '' !== (string) $args['search'] ) {
113 $comment_args['search'] = (string) $args['search'];
114 }
115
116 /**
117 * Filter the `WP_Comment_Query` args used to populate the
118 * recycle bin's comments. Mirror of
119 * `openstation_recycle_bin_query_args` for comments.
120 *
121 * @param array $comment_args Args passed to `get_comments()`.
122 * @param array $args Caller-provided args.
123 */
124 $comment_args = apply_filters(
125 'openstation_recycle_bin_comment_query_args',
126 $comment_args,
127 $args
128 );
129
130 $comments = get_comments( $comment_args );
131 if ( is_array( $comments ) ) {
132 foreach ( $comments as $comment ) {
133 if ( ! openstation_recycle_bin_user_can_view_comment( $comment ) ) {
134 continue;
135 }
136 $items_comments[] = openstation_recycle_bin_shape_comment_item( $comment );
137 }
138 }
139 }
140
141 // Files-on-the-Desktop trash — soft-trashed placements
142 // (shortcuts) and folders. Returned in the same item shape so
143 // the JS layer treats them uniformly. The `placement` and
144 // `folder` types route to the desktop-files trash module on
145 // restore / purge (see `openstation_recycle_bin_handle_files_*`).
146 $items_files = array();
147 // Map UI filter → set of `type` values to keep from the
148 // files-on-desktop helper. The "Shortcuts" segment in the bin
149 // UI now covers both registered icons (`shortcut`) AND user
150 // folders (`folder`) — restore + purge dispatch still routes
151 // each row by its individual type, so the merge is purely
152 // visual.
153 // "Desktop" is the unified bucket — every files-on-the-desktop
154 // trash row regardless of internal type (shortcut / folder /
155 // placement). Per-row dispatch on restore + purge still uses
156 // the row's distinct `type` so the merge is purely visual.
157 $wanted_files_types = array();
158 switch ( $type ) {
159 case '':
160 case 'desktop':
161 $wanted_files_types = array( 'shortcut', 'folder', 'placement' );
162 break;
163 case 'shortcut':
164 case 'placement':
165 case 'folder':
166 $wanted_files_types = array( $type );
167 break;
168 }
169 if (
170 ! empty( $wanted_files_types )
171 && function_exists( 'openstation_files_list_trashed_for_recycle_bin' )
172 ) {
173 $file_items = openstation_files_list_trashed_for_recycle_bin(
174 get_current_user_id()
175 );
176 foreach ( (array) $file_items as $item ) {
177 if ( ! in_array( (string) $item['type'], $wanted_files_types, true ) ) {
178 continue;
179 }
180 $items_files[] = $item;
181 }
182 }
183
184 $items = array_merge( $items_posts, $items_comments, $items_files );
185
186 // Sort the merged list chronologically by deleted_at desc. The
187 // shape always carries a sortable string in `deleted_at`, so a
188 // straight string compare is enough (`Y-m-d H:i:s` is sortable
189 // lexicographically).
190 usort(
191 $items,
192 static function ( $a, $b ) {
193 return strcmp( (string) $b['deleted_at'], (string) $a['deleted_at'] );
194 }
195 );
196
197 // `total` reports the GLOBAL trash count (every type, every
198 // row, ignoring the current filter / search). The dock-tile
199 // + desktop-icon badge consume this directly — `setRecycleBinBadge`
200 // only cares about "how many things are sitting in the bin
201 // right now". A future paginated UI that needs a filtered
202 // count can compute it from `count( $items )` itself.
203 $total = openstation_recycle_bin_count();
204
205 $offset = max( 0, ( max( 1, (int) $args['page'] ) - 1 ) * $per_page );
206 $sliced = array_slice( $items, $offset, $per_page );
207
208 /**
209 * Filter the final list of items returned to the JS layer.
210 *
211 * @param array $items Shaped list (id, title, type, deleted_at, …).
212 * @param array|null $query Underlying post query, or null for the
213 * merged post+comment shape.
214 */
215 $sliced = apply_filters( 'openstation_recycle_bin_items', $sliced, null );
216
217 return array(
218 'items' => $sliced,
219 'total' => $total,
220 );
221 }
222
223 /**
224 * Total number of items in the recycle bin, summed across every
225 * tracked source (post types + comments).
226 *
227 * Cheaper than `openstation_recycle_bin_get_items()` because it never
228 * loads the row data — just the COUNT(*) under the hood. Used by
229 * the badge on the dock tile + desktop icon, and by the REST
230 * `/count` endpoint subscribers refresh on broadcasts.
231 *
232 * The post component mirrors the per-item `edit_post` gate the list
233 * applies, at the aggregate level: tracked types the user cannot edit
234 * at all contribute zero, and types where the user can only edit their
235 * own posts are counted author-scoped — so the badge never discloses
236 * the global trash total to low-capability users.
237 *
238 * @return int
239 */
240 function openstation_recycle_bin_count() {
241 $post_types = openstation_recycle_bin_capture_post_types();
242
243 // Bucket the tracked types by what the current user may edit:
244 // full count when they hold the type's `edit_others_posts`,
245 // author-scoped count when they only hold `edit_posts`, nothing
246 // otherwise. At most two cheap COUNT(*) queries.
247 $all_types = array();
248 $own_types = array();
249 foreach ( $post_types as $post_type ) {
250 $post_type_obj = get_post_type_object( $post_type );
251 if ( ! $post_type_obj ) {
252 continue;
253 }
254 if ( ! current_user_can( $post_type_obj->cap->edit_posts ) ) {
255 continue;
256 }
257 if ( current_user_can( $post_type_obj->cap->edit_others_posts ) ) {
258 $all_types[] = $post_type;
259 } else {
260 $own_types[] = $post_type;
261 }
262 }
263
264 $post_count = 0;
265 if ( ! empty( $all_types ) ) {
266 $post_query = new WP_Query(
267 array(
268 'post_type' => $all_types,
269 'post_status' => 'trash',
270 'posts_per_page' => 1,
271 'fields' => 'ids',
272 'no_found_rows' => false,
273 'suppress_filters' => false,
274 )
275 );
276 $post_count += (int) $post_query->found_posts;
277 }
278 if ( ! empty( $own_types ) ) {
279 $own_query = new WP_Query(
280 array(
281 'post_type' => $own_types,
282 'post_status' => 'trash',
283 'posts_per_page' => 1,
284 'fields' => 'ids',
285 'no_found_rows' => false,
286 'suppress_filters' => false,
287 'author' => get_current_user_id(),
288 )
289 );
290 $post_count += (int) $own_query->found_posts;
291 }
292
293 $comment_count = 0;
294 if ( openstation_recycle_bin_comments_enabled() ) {
295 $comment_count = (int) get_comments(
296 array(
297 'status' => 'trash',
298 'count' => true,
299 )
300 );
301 }
302
303 $files_count = 0;
304 if ( function_exists( 'openstation_files_count_trashed_for_recycle_bin' ) ) {
305 $files_count = (int) openstation_files_count_trashed_for_recycle_bin( get_current_user_id() );
306 }
307
308 $total = $post_count + $comment_count + $files_count;
309
310 /**
311 * Filter the total count surfaced to the badge.
312 *
313 * @param int $total Default sum (posts + comments + files visible to the user).
314 * @param int $post_count Items in trash from the post-type query, capability-scoped
315 * to what the current user can edit.
316 * @param int $comment_count Items in trash from the comment query.
317 * @param int $files_count Items in trash from the desktop-files trash.
318 */
319 return (int) apply_filters( 'openstation_recycle_bin_count', $total, $post_count, $comment_count, $files_count );
320 }
321
322 /**
323 * Whether comments are part of the bin. Filterable so installs
324 * that don't moderate comments at all (read-only blogs, headless
325 * setups) can hide the segment without touching the JS.
326 *
327 * @return bool
328 */
329 function openstation_recycle_bin_comments_enabled() {
330 $on = current_user_can( 'moderate_comments' );
331
332 /**
333 * Filter whether the recycle bin tracks comments.
334 *
335 * @param bool $on Default: current user has `moderate_comments`.
336 */
337 return (bool) apply_filters( 'openstation_recycle_bin_comments_enabled', $on );
338 }
339
340 /**
341 * Whether the current user can see a given trashed item.
342 *
343 * Mirrors `current_user_can( 'edit_post', $id )` for the consistent
344 * "if you can edit it, you can manage its trash" rule. Filterable for
345 * stricter / looser policies.
346 *
347 * @param WP_Post $post Trashed post.
348 * @return bool
349 */
350 function openstation_recycle_bin_user_can_view( $post ) {
351 $can = current_user_can( 'edit_post', $post->ID );
352
353 /**
354 * Filter whether the current user can see a given trashed item.
355 *
356 * @param bool $can Default: edit_post capability check.
357 * @param WP_Post $post Trashed post.
358 */
359 return (bool) apply_filters( 'openstation_recycle_bin_user_can_view', $can, $post );
360 }
361
362 /**
363 * Whether the current user can restore a given trashed item.
364 *
365 * @param WP_Post $post Trashed post.
366 * @return bool
367 */
368 function openstation_recycle_bin_user_can_restore( $post ) {
369 $can = current_user_can( 'delete_post', $post->ID );
370
371 /**
372 * Filter whether the current user can restore a given trashed item.
373 *
374 * @param bool $can Default: delete_post capability check (the same
375 * gate WP itself uses for trash/untrash).
376 * @param WP_Post $post Trashed post.
377 */
378 return (bool) apply_filters( 'openstation_recycle_bin_user_can_restore', $can, $post );
379 }
380
381 /**
382 * Whether the current user can permanently delete a trashed item.
383 *
384 * @param WP_Post $post Trashed post.
385 * @return bool
386 */
387 function openstation_recycle_bin_user_can_purge( $post ) {
388 $can = current_user_can( 'delete_post', $post->ID );
389
390 /**
391 * Filter whether the current user can permanently delete a trashed item.
392 *
393 * @param bool $can Default: delete_post capability check.
394 * @param WP_Post $post Trashed post.
395 */
396 return (bool) apply_filters( 'openstation_recycle_bin_user_can_purge', $can, $post );
397 }
398
399 /**
400 * Capability gates for trashed comments. Mirror of the post gates,
401 * with `edit_comment`/`moderate_comments` as the WP-native checks.
402 *
403 * @param WP_Comment $comment Trashed comment.
404 * @return bool
405 */
406 function openstation_recycle_bin_user_can_view_comment( $comment ) {
407 $can = current_user_can( 'edit_comment', $comment->comment_ID );
408
409 /**
410 * @param bool $can Default: edit_comment capability check.
411 * @param WP_Comment $comment Trashed comment.
412 */
413 return (bool) apply_filters( 'openstation_recycle_bin_user_can_view_comment', $can, $comment );
414 }
415
416 /**
417 * @param WP_Comment $comment Trashed comment.
418 * @return bool
419 */
420 function openstation_recycle_bin_user_can_restore_comment( $comment ) {
421 $can = current_user_can( 'edit_comment', $comment->comment_ID );
422
423 /**
424 * @param bool $can Default: edit_comment capability check.
425 * @param WP_Comment $comment Trashed comment.
426 */
427 return (bool) apply_filters( 'openstation_recycle_bin_user_can_restore_comment', $can, $comment );
428 }
429
430 /**
431 * @param WP_Comment $comment Trashed comment.
432 * @return bool
433 */
434 function openstation_recycle_bin_user_can_purge_comment( $comment ) {
435 $can = current_user_can( 'edit_comment', $comment->comment_ID );
436
437 /**
438 * @param bool $can Default: edit_comment capability check.
439 * @param WP_Comment $comment Trashed comment.
440 */
441 return (bool) apply_filters( 'openstation_recycle_bin_user_can_purge_comment', $can, $comment );
442 }
443
444 /**
445 * Shape a `WP_Comment` into the JSON the JS table consumes.
446 *
447 * Same field set as the post shape so the React-style table doesn't
448 * have to special-case the row by `type`. The `title` reads as
449 * "<author> on <post title>"; the `subtitle` carries a 100-char
450 * excerpt of `comment_content`.
451 *
452 * @param WP_Comment $comment Trashed comment.
453 * @return array
454 */
455 function openstation_recycle_bin_shape_comment_item( $comment ) {
456 $user_id = (int) get_comment_meta( $comment->comment_ID, '_desktop_mode_trash_user_id', true );
457 $deleted_at = (string) get_comment_meta( $comment->comment_ID, '_desktop_mode_trash_time_gmt', true );
458
459 if ( '' === $deleted_at ) {
460 $deleted_at = (string) $comment->comment_date_gmt;
461 }
462
463 $parent = $comment->comment_post_ID ? get_post( (int) $comment->comment_post_ID ) : null;
464 $parent_text = $parent ? openstation_recycle_bin_plain_text( get_the_title( $parent ) ) : '';
465 $author = $comment->comment_author
466 ? (string) $comment->comment_author
467 : __( 'Anonymous', 'desktop-mode' );
468
469 $title = '' !== $parent_text
470 ? sprintf(
471 /* translators: 1: comment author. 2: parent post title. */
472 __( '%1$s on %2$s', 'desktop-mode' ),
473 $author,
474 $parent_text
475 )
476 : $author;
477
478 $subtitle = wp_trim_words( openstation_recycle_bin_plain_text( (string) $comment->comment_content ), 18, '' );
479
480 $user = $user_id ? get_userdata( $user_id ) : false;
481 $user_name = $user ? $user->display_name : '';
482
483 $item = array(
484 'id' => (int) $comment->comment_ID,
485 'type' => 'comment',
486 'type_label' => __( 'Comment', 'desktop-mode' ),
487 'title' => $title,
488 'subtitle' => $subtitle,
489 'mime' => '',
490 'preview' => '',
491 'icon' => 'dashicons-admin-comments',
492 'deleted_at' => $deleted_at,
493 'deleted_by' => $user_name,
494 'deleted_by_id' => $user_id,
495 'can_restore' => openstation_recycle_bin_user_can_restore_comment( $comment ),
496 'can_purge' => openstation_recycle_bin_user_can_purge_comment( $comment ),
497 'edit_link' => (string) get_edit_comment_link( $comment->comment_ID ),
498 );
499
500 /**
501 * Filter the comment item shape.
502 *
503 * @param array $item Item shape.
504 * @param WP_Comment $comment Source comment.
505 */
506 return (array) apply_filters( 'openstation_recycle_bin_comment_item', $item, $comment );
507 }
508
509 /**
510 * Collapse a title/subtitle to plain text for the wire.
511 *
512 * `get_the_title()` runs the `the_title` filter chain, and
513 * `wptexturize` in it encodes punctuation as numeric entities
514 * (apostrophe → `&#8217;`, quotes, dashes) — correct for HTML
515 * output, wrong for the bin table, which renders every cell via
516 * `textContent` and would show the literal entity. Strip tags first,
517 * then decode entities back to characters.
518 *
519 * @param string $text Raw filtered text.
520 * @return string
521 */
522 function openstation_recycle_bin_plain_text( $text ) {
523 return html_entity_decode(
524 wp_strip_all_tags( (string) $text ),
525 ENT_QUOTES,
526 get_bloginfo( 'charset' )
527 );
528 }
529
530 /**
531 * Shape one WP_Post into the JSON the JS table consumes.
532 *
533 * @param WP_Post $post Trashed post.
534 * @return array
535 */
536 function openstation_recycle_bin_shape_item( $post ) {
537 $user_id = (int) get_post_meta( $post->ID, '_desktop_mode_trash_user_id', true );
538 $deleted_at = (string) get_post_meta( $post->ID, '_desktop_mode_trash_time_gmt', true );
539
540 // Fall back to post_modified_gmt — set when wp_trash_post runs and
541 // reasonable for items captured before the recycle bin existed.
542 if ( '' === $deleted_at ) {
543 $deleted_at = (string) $post->post_modified_gmt;
544 }
545
546 $type = (string) $post->post_type;
547 $title = openstation_recycle_bin_plain_text( (string) get_the_title( $post ) );
548 $mime = (string) $post->post_mime_type;
549 $preview = '';
550 $icon = '';
551 $subtitle = '';
552
553 if ( 'attachment' === $type ) {
554 // Use the medium thumbnail when available, else core's default
555 // "broken image" placeholder. `wp_get_attachment_image_src()`
556 // returns false when the file is gone, so we always coerce.
557 $thumb = wp_get_attachment_image_src( $post->ID, array( 64, 64 ), true );
558 if ( is_array( $thumb ) ) {
559 $preview = (string) $thumb[0];
560 }
561 $icon = openstation_recycle_bin_icon_for_mime( $mime );
562 $subtitle = $mime;
563 } elseif ( 'post' === $type ) {
564 $icon = 'dashicons-admin-post';
565 $excerpt = (string) $post->post_excerpt;
566 $subtitle = wp_trim_words( openstation_recycle_bin_plain_text( $excerpt ? $excerpt : (string) $post->post_content ), 18, '' );
567 } elseif ( 'page' === $type ) {
568 $icon = 'dashicons-admin-page';
569 $subtitle = wp_trim_words( openstation_recycle_bin_plain_text( (string) $post->post_content ), 18, '' );
570 } else {
571 // Custom post types: reuse the type's own menu Dashicon when it
572 // registered one, so a trashed product row reads as a product
573 // instead of a generic file. Content excerpt as the subtitle,
574 // same as posts.
575 $icon = 'dashicons-media-default';
576 $post_type_obj = get_post_type_object( $type );
577 if (
578 $post_type_obj
579 && is_string( $post_type_obj->menu_icon )
580 && str_starts_with( $post_type_obj->menu_icon, 'dashicons-' )
581 ) {
582 $icon = $post_type_obj->menu_icon;
583 }
584 $excerpt = (string) $post->post_excerpt;
585 $subtitle = wp_trim_words( openstation_recycle_bin_plain_text( $excerpt ? $excerpt : (string) $post->post_content ), 18, '' );
586 }
587
588 $user = $user_id ? get_userdata( $user_id ) : false;
589 $user_name = $user ? $user->display_name : '';
590
591 // Resolve a human label for the type badge. `attachment` collapses
592 // to "Media" to match the toolbar filter; every other registered
593 // post type uses its singular label so CPTs read correctly (e.g.
594 // "Product" for WooCommerce). Unknown types fall back to a
595 // title-cased slug.
596 if ( 'attachment' === $type ) {
597 $type_label = __( 'Media', 'desktop-mode' );
598 } else {
599 $post_type_obj = get_post_type_object( $type );
600 if ( $post_type_obj && isset( $post_type_obj->labels->singular_name ) && '' !== (string) $post_type_obj->labels->singular_name ) {
601 $type_label = (string) $post_type_obj->labels->singular_name;
602 } else {
603 $type_label = ucwords( str_replace( array( '_', '-' ), ' ', $type ) );
604 }
605 }
606
607 $item = array(
608 'id' => (int) $post->ID,
609 'type' => $type,
610 'type_label' => $type_label,
611 'title' => '' !== $title ? $title : sprintf( '#%d', $post->ID ),
612 'subtitle' => $subtitle,
613 'mime' => $mime,
614 'preview' => $preview,
615 'icon' => $icon,
616 'deleted_at' => $deleted_at,
617 'deleted_by' => $user_name,
618 'deleted_by_id' => $user_id,
619 'can_restore' => openstation_recycle_bin_user_can_restore( $post ),
620 'can_purge' => openstation_recycle_bin_user_can_purge( $post ),
621 'edit_link' => (string) get_edit_post_link( $post->ID, 'raw' ),
622 );
623
624 /**
625 * Filter the item shape for the recycle bin table.
626 *
627 * Add custom columns or override the icon/preview for a custom
628 * post type. The id/type/deleted_at trio is load-bearing — keep
629 * them in the returned array.
630 *
631 * @param array $item Item shape.
632 * @param WP_Post $post Source post.
633 */
634 return (array) apply_filters( 'openstation_recycle_bin_item', $item, $post );
635 }
636
637 /**
638 * Map a mime type to a Dashicon for the type cell.
639 *
640 * @param string $mime Mime type.
641 * @return string Dashicon class.
642 */
643 function openstation_recycle_bin_icon_for_mime( $mime ) {
644 if ( '' === $mime ) {
645 return 'dashicons-media-default';
646 }
647 if ( str_starts_with( $mime, 'image/' ) ) {
648 return 'dashicons-format-image';
649 }
650 if ( str_starts_with( $mime, 'video/' ) ) {
651 return 'dashicons-format-video';
652 }
653 if ( str_starts_with( $mime, 'audio/' ) ) {
654 return 'dashicons-format-audio';
655 }
656 switch ( $mime ) {
657 case 'application/pdf':
658 return 'dashicons-pdf';
659 case 'application/zip':
660 case 'application/x-zip-compressed':
661 case 'application/x-tar':
662 case 'application/x-rar-compressed':
663 return 'dashicons-media-archive';
664 case 'text/plain':
665 case 'text/html':
666 case 'text/csv':
667 return 'dashicons-media-text';
668 case 'application/msword':
669 case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
670 return 'dashicons-media-document';
671 case 'application/vnd.ms-excel':
672 case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
673 return 'dashicons-media-spreadsheet';
674 case 'application/json':
675 return 'dashicons-media-code';
676 }
677 return 'dashicons-media-default';
678 }
679
680 /**
681 * Restore a single trashed item.
682 *
683 * Dispatches by `type`: comments go through `wp_untrash_comment`,
684 * everything else through `wp_untrash_post`. The legacy single-arg
685 * call (id only) defaults to `'post'` so older clients that haven't
686 * migrated to the typed API keep working.
687 *
688 * @param int $id Post id (or comment id when `$type === 'comment'`).
689 * @param string $type Entity type — '', 'post', 'page', 'attachment', or 'comment'.
690 * @return true|WP_Error
691 */
692 function openstation_recycle_bin_restore( $id, $type = '' ) {
693 $id = (int) $id;
694 if ( 'comment' === $type ) {
695 return openstation_recycle_bin_restore_comment( $id );
696 }
697 if ( ( 'placement' === $type || 'shortcut' === $type ) && function_exists( 'openstation_files_restore_placement' ) ) {
698 return openstation_files_restore_placement( get_current_user_id(), $id );
699 }
700 if ( 'folder' === $type && function_exists( 'openstation_files_restore_folder' ) ) {
701 return openstation_files_restore_folder( get_current_user_id(), $id );
702 }
703
704 $post = get_post( $id );
705 if ( ! $post ) {
706 return new WP_Error( 'openstation_recycle_bin_not_found', __( 'Item not found.', 'desktop-mode' ), array( 'status' => 404 ) );
707 }
708 if ( 'trash' !== $post->post_status ) {
709 return new WP_Error( 'openstation_recycle_bin_not_trashed', __( 'Item is not in the trash.', 'desktop-mode' ), array( 'status' => 409 ) );
710 }
711 if ( ! openstation_recycle_bin_user_can_restore( $post ) ) {
712 return new WP_Error( 'openstation_recycle_bin_forbidden', __( 'You are not allowed to restore this item.', 'desktop-mode' ), array( 'status' => 403 ) );
713 }
714
715 /**
716 * Fires before a recycle-bin item is restored.
717 *
718 * @param int $id Post id about to be restored.
719 * @param WP_Post $post Trashed post object.
720 */
721 do_action( 'openstation_recycle_bin_before_restore', $id, $post );
722
723 $ok = wp_untrash_post( $id );
724 if ( ! $ok ) {
725 return new WP_Error( 'openstation_recycle_bin_restore_failed', __( 'Failed to restore item.', 'desktop-mode' ), array( 'status' => 500 ) );
726 }
727
728 delete_post_meta( $id, '_desktop_mode_trash_user_id' );
729 delete_post_meta( $id, '_desktop_mode_trash_time_gmt' );
730
731 /**
732 * Fires after a recycle-bin item is restored.
733 *
734 * @param int $id Post id that was restored.
735 */
736 do_action( 'openstation_recycle_bin_after_restore', $id );
737
738 return true;
739 }
740
741 /**
742 * Restore a single trashed comment.
743 *
744 * @param int $comment_id Comment id.
745 * @return true|WP_Error
746 */
747 function openstation_recycle_bin_restore_comment( $comment_id ) {
748 $comment_id = (int) $comment_id;
749 $comment = get_comment( $comment_id );
750
751 if ( ! $comment ) {
752 return new WP_Error( 'openstation_recycle_bin_not_found', __( 'Comment not found.', 'desktop-mode' ), array( 'status' => 404 ) );
753 }
754 if ( 'trash' !== $comment->comment_approved ) {
755 return new WP_Error( 'openstation_recycle_bin_not_trashed', __( 'Comment is not in the trash.', 'desktop-mode' ), array( 'status' => 409 ) );
756 }
757 if ( ! openstation_recycle_bin_user_can_restore_comment( $comment ) ) {
758 return new WP_Error( 'openstation_recycle_bin_forbidden', __( 'You are not allowed to restore this comment.', 'desktop-mode' ), array( 'status' => 403 ) );
759 }
760
761 /**
762 * Fires before a comment is restored from the recycle bin.
763 *
764 * @param int $comment_id Comment id.
765 * @param WP_Comment $comment Trashed comment.
766 */
767 do_action( 'openstation_recycle_bin_before_restore_comment', $comment_id, $comment );
768
769 $ok = wp_untrash_comment( $comment_id );
770 if ( ! $ok ) {
771 return new WP_Error( 'openstation_recycle_bin_restore_failed', __( 'Failed to restore comment.', 'desktop-mode' ), array( 'status' => 500 ) );
772 }
773
774 delete_comment_meta( $comment_id, '_desktop_mode_trash_user_id' );
775 delete_comment_meta( $comment_id, '_desktop_mode_trash_time_gmt' );
776
777 /**
778 * Fires after a comment is restored from the recycle bin.
779 *
780 * @param int $comment_id Comment id.
781 */
782 do_action( 'openstation_recycle_bin_after_restore_comment', $comment_id );
783
784 return true;
785 }
786
787 /**
788 * Permanently delete a single trashed item. Dispatches by `$type`.
789 *
790 * @param int $id Post id (or comment id when `$type === 'comment'`).
791 * @param string $type Entity type — '', 'post', 'page', 'attachment', or 'comment'.
792 * @return true|WP_Error
793 */
794 function openstation_recycle_bin_purge( $id, $type = '' ) {
795 $id = (int) $id;
796 if ( 'comment' === $type ) {
797 return openstation_recycle_bin_purge_comment( $id );
798 }
799 if ( ( 'placement' === $type || 'shortcut' === $type ) && function_exists( 'openstation_files_purge_placement' ) ) {
800 return openstation_files_purge_placement( get_current_user_id(), $id );
801 }
802 if ( 'folder' === $type && function_exists( 'openstation_files_purge_folder' ) ) {
803 return openstation_files_purge_folder( get_current_user_id(), $id );
804 }
805
806 $post = get_post( $id );
807 if ( ! $post ) {
808 return new WP_Error( 'openstation_recycle_bin_not_found', __( 'Item not found.', 'desktop-mode' ), array( 'status' => 404 ) );
809 }
810 if ( 'trash' !== $post->post_status ) {
811 return new WP_Error( 'openstation_recycle_bin_not_trashed', __( 'Item is not in the trash.', 'desktop-mode' ), array( 'status' => 409 ) );
812 }
813 if ( ! openstation_recycle_bin_user_can_purge( $post ) ) {
814 return new WP_Error( 'openstation_recycle_bin_forbidden', __( 'You are not allowed to permanently delete this item.', 'desktop-mode' ), array( 'status' => 403 ) );
815 }
816
817 /**
818 * Fires before a recycle-bin item is permanently deleted.
819 *
820 * @param int $id Post id about to be deleted.
821 * @param WP_Post $post Trashed post object.
822 */
823 do_action( 'openstation_recycle_bin_before_purge', $id, $post );
824
825 if ( 'attachment' === $post->post_type ) {
826 // Force-delete (`true`) removes the attachment and its file
827 // permanently. (The item is already trashed, so even with
828 // MEDIA_TRASH enabled core would not re-route it to trash;
829 // `true` simply makes the purge intent explicit.)
830 $result = wp_delete_attachment( $id, true );
831 } else {
832 $result = wp_delete_post( $id, true );
833 }
834
835 if ( ! $result ) {
836 return new WP_Error( 'openstation_recycle_bin_purge_failed', __( 'Failed to permanently delete item.', 'desktop-mode' ), array( 'status' => 500 ) );
837 }
838
839 /**
840 * Fires after a recycle-bin item is permanently deleted.
841 *
842 * @param int $id Post id that was purged.
843 * @param string $type Post type of the purged item.
844 */
845 do_action( 'openstation_recycle_bin_after_purge', $id, $post->post_type );
846
847 return true;
848 }
849
850 /**
851 * Permanently delete a single trashed comment.
852 *
853 * @param int $comment_id Comment id.
854 * @return true|WP_Error
855 */
856 function openstation_recycle_bin_purge_comment( $comment_id ) {
857 $comment_id = (int) $comment_id;
858 $comment = get_comment( $comment_id );
859
860 if ( ! $comment ) {
861 return new WP_Error( 'openstation_recycle_bin_not_found', __( 'Comment not found.', 'desktop-mode' ), array( 'status' => 404 ) );
862 }
863 if ( 'trash' !== $comment->comment_approved ) {
864 return new WP_Error( 'openstation_recycle_bin_not_trashed', __( 'Comment is not in the trash.', 'desktop-mode' ), array( 'status' => 409 ) );
865 }
866 if ( ! openstation_recycle_bin_user_can_purge_comment( $comment ) ) {
867 return new WP_Error( 'openstation_recycle_bin_forbidden', __( 'You are not allowed to permanently delete this comment.', 'desktop-mode' ), array( 'status' => 403 ) );
868 }
869
870 /**
871 * Fires before a comment is permanently deleted via the bin.
872 *
873 * @param int $comment_id Comment id.
874 * @param WP_Comment $comment Trashed comment.
875 */
876 do_action( 'openstation_recycle_bin_before_purge_comment', $comment_id, $comment );
877
878 $result = wp_delete_comment( $comment_id, true );
879
880 if ( ! $result ) {
881 return new WP_Error( 'openstation_recycle_bin_purge_failed', __( 'Failed to permanently delete comment.', 'desktop-mode' ), array( 'status' => 500 ) );
882 }
883
884 /**
885 * Fires after a comment is permanently deleted via the bin.
886 *
887 * @param int $comment_id Comment id.
888 */
889 do_action( 'openstation_recycle_bin_after_purge_comment', $comment_id );
890
891 return true;
892 }
893
894 /**
895 * Empty the recycle bin for the current user.
896 *
897 * Honors the same capability gate as a single purge — items the user
898 * can't permanently delete are skipped (not silently dropped).
899 *
900 * Processes at most one chunk per call. The cap protects against PHP
901 * timeouts on large bins; the client iterates while `remaining > 0`
902 * (and bails when `remaining === skipped`, i.e. nothing the user can
903 * purge is left). Site owners with longer execution budgets can tune
904 * the chunk size via the `openstation_recycle_bin_empty_chunk_size`
905 * filter.
906 *
907 * @return array {
908 * @type int $purged Items successfully purged in this call.
909 * @type int $skipped Items skipped (capability or error).
910 * @type int $remaining Items still in the bin after this call (across pages).
911 * }
912 */
913 function openstation_recycle_bin_empty() {
914 $purged = 0;
915 $skipped = 0;
916
917 /**
918 * Filter the per-call chunk size for the empty-bin loop.
919 *
920 * `openstation_recycle_bin_empty()` only purges this many items
921 * per invocation. The client iterates while `remaining > 0`. The
922 * default (200) is conservative for shared hosts; sites with
923 * generous PHP execution limits can raise it to make emptying a
924 * large bin take fewer roundtrips.
925 *
926 * @param int $chunk_size Items processed per call. Default 200.
927 */
928 $chunk_size = (int) apply_filters( 'openstation_recycle_bin_empty_chunk_size', 200 );
929 if ( $chunk_size < 1 ) {
930 $chunk_size = 1;
931 }
932
933 // Loop in chunks — `wp_delete_post()` is cheap individually but
934 // hammering it on a 10k-item bin without yielding back to PHP can
935 // still time out. The client re-invokes us until `remaining` hits
936 // zero (or stalls at `skipped`).
937 $batch = openstation_recycle_bin_get_items(
938 array(
939 'per_page' => $chunk_size,
940 'page' => 1,
941 )
942 );
943 foreach ( $batch['items'] as $item ) {
944 $result = openstation_recycle_bin_purge(
945 (int) $item['id'],
946 (string) ( $item['type'] ?? '' )
947 );
948 if ( is_wp_error( $result ) ) {
949 ++$skipped;
950 } else {
951 ++$purged;
952 }
953 }
954
955 /**
956 * Fires after the recycle bin is emptied.
957 *
958 * @param int $purged Items successfully purged in this call.
959 * @param int $skipped Items skipped (capability or error).
960 */
961 do_action( 'openstation_recycle_bin_emptied', $purged, $skipped );
962
963 return array(
964 'purged' => $purged,
965 'skipped' => $skipped,
966 'remaining' => max( 0, $batch['total'] - $purged ),
967 );
968 }
969