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

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