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

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

958 lines 30.5 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.19.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.19.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.19.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.21.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.19.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.21.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 * @since 0.21.0
239 *
240 * @return int
241 */
242 function desktop_mode_recycle_bin_count() {
243 $post_types = desktop_mode_recycle_bin_capture_post_types();
244
245 $post_query = new WP_Query(
246 array(
247 'post_type' => $post_types,
248 'post_status' => 'trash',
249 'posts_per_page' => 1,
250 'fields' => 'ids',
251 'no_found_rows' => false,
252 'suppress_filters' => false,
253 )
254 );
255 $post_count = (int) $post_query->found_posts;
256
257 $comment_count = 0;
258 if ( desktop_mode_recycle_bin_comments_enabled() ) {
259 $comment_count = (int) get_comments(
260 array(
261 'status' => 'trash',
262 'count' => true,
263 )
264 );
265 }
266
267 $files_count = 0;
268 if ( function_exists( 'desktop_mode_files_count_trashed_for_recycle_bin' ) ) {
269 $files_count = (int) desktop_mode_files_count_trashed_for_recycle_bin( get_current_user_id() );
270 }
271
272 $total = $post_count + $comment_count + $files_count;
273
274 /**
275 * Filter the total count surfaced to the badge.
276 *
277 * @since 0.21.0
278 *
279 * @param int $total Default sum (posts + comments + files visible to the user).
280 * @param int $post_count Items in trash from the post-type query.
281 * @param int $comment_count Items in trash from the comment query.
282 * @param int $files_count Items in trash from the desktop-files trash (since 0.8.0).
283 */
284 return (int) apply_filters( 'desktop_mode_recycle_bin_count', $total, $post_count, $comment_count, $files_count );
285 }
286
287 /**
288 * Whether comments are part of the bin. Filterable so installs
289 * that don't moderate comments at all (read-only blogs, headless
290 * setups) can hide the segment without touching the JS.
291 *
292 * @since 0.21.0
293 *
294 * @return bool
295 */
296 function desktop_mode_recycle_bin_comments_enabled() {
297 $on = current_user_can( 'moderate_comments' );
298
299 /**
300 * Filter whether the recycle bin tracks comments.
301 *
302 * @since 0.21.0
303 *
304 * @param bool $on Default: current user has `moderate_comments`.
305 */
306 return (bool) apply_filters( 'desktop_mode_recycle_bin_comments_enabled', $on );
307 }
308
309 /**
310 * Whether the current user can see a given trashed item.
311 *
312 * Mirrors `current_user_can( 'edit_post', $id )` for the consistent
313 * "if you can edit it, you can manage its trash" rule. Filterable for
314 * stricter / looser policies.
315 *
316 * @since 0.19.0
317 *
318 * @param WP_Post $post Trashed post.
319 * @return bool
320 */
321 function desktop_mode_recycle_bin_user_can_view( $post ) {
322 $can = current_user_can( 'edit_post', $post->ID );
323
324 /**
325 * Filter whether the current user can see a given trashed item.
326 *
327 * @since 0.19.0
328 *
329 * @param bool $can Default: edit_post capability check.
330 * @param WP_Post $post Trashed post.
331 */
332 return (bool) apply_filters( 'desktop_mode_recycle_bin_user_can_view', $can, $post );
333 }
334
335 /**
336 * Whether the current user can restore a given trashed item.
337 *
338 * @since 0.19.0
339 *
340 * @param WP_Post $post Trashed post.
341 * @return bool
342 */
343 function desktop_mode_recycle_bin_user_can_restore( $post ) {
344 $can = current_user_can( 'delete_post', $post->ID );
345
346 /**
347 * Filter whether the current user can restore a given trashed item.
348 *
349 * @since 0.19.0
350 *
351 * @param bool $can Default: delete_post capability check (the same
352 * gate WP itself uses for trash/untrash).
353 * @param WP_Post $post Trashed post.
354 */
355 return (bool) apply_filters( 'desktop_mode_recycle_bin_user_can_restore', $can, $post );
356 }
357
358 /**
359 * Whether the current user can permanently delete a trashed item.
360 *
361 * @since 0.19.0
362 *
363 * @param WP_Post $post Trashed post.
364 * @return bool
365 */
366 function desktop_mode_recycle_bin_user_can_purge( $post ) {
367 $can = current_user_can( 'delete_post', $post->ID );
368
369 /**
370 * Filter whether the current user can permanently delete a trashed item.
371 *
372 * @since 0.19.0
373 *
374 * @param bool $can Default: delete_post capability check.
375 * @param WP_Post $post Trashed post.
376 */
377 return (bool) apply_filters( 'desktop_mode_recycle_bin_user_can_purge', $can, $post );
378 }
379
380 /**
381 * Capability gates for trashed comments. Mirror of the post gates,
382 * with `edit_comment`/`moderate_comments` as the WP-native checks.
383 *
384 * @since 0.21.0
385 *
386 * @param WP_Comment $comment Trashed comment.
387 * @return bool
388 */
389 function desktop_mode_recycle_bin_user_can_view_comment( $comment ) {
390 $can = current_user_can( 'edit_comment', $comment->comment_ID );
391
392 /**
393 * @since 0.21.0
394 * @param bool $can Default: edit_comment capability check.
395 * @param WP_Comment $comment Trashed comment.
396 */
397 return (bool) apply_filters( 'desktop_mode_recycle_bin_user_can_view_comment', $can, $comment );
398 }
399
400 /**
401 * @since 0.21.0
402 *
403 * @param WP_Comment $comment Trashed comment.
404 * @return bool
405 */
406 function desktop_mode_recycle_bin_user_can_restore_comment( $comment ) {
407 $can = current_user_can( 'edit_comment', $comment->comment_ID );
408
409 /**
410 * @since 0.21.0
411 * @param bool $can Default: edit_comment capability check.
412 * @param WP_Comment $comment Trashed comment.
413 */
414 return (bool) apply_filters( 'desktop_mode_recycle_bin_user_can_restore_comment', $can, $comment );
415 }
416
417 /**
418 * @since 0.21.0
419 *
420 * @param WP_Comment $comment Trashed comment.
421 * @return bool
422 */
423 function desktop_mode_recycle_bin_user_can_purge_comment( $comment ) {
424 $can = current_user_can( 'edit_comment', $comment->comment_ID );
425
426 /**
427 * @since 0.21.0
428 * @param bool $can Default: edit_comment capability check.
429 * @param WP_Comment $comment Trashed comment.
430 */
431 return (bool) apply_filters( 'desktop_mode_recycle_bin_user_can_purge_comment', $can, $comment );
432 }
433
434 /**
435 * Shape a `WP_Comment` into the JSON the JS table consumes.
436 *
437 * Same field set as the post shape so the React-style table doesn't
438 * have to special-case the row by `type`. The `title` reads as
439 * "<author> on <post title>"; the `subtitle` carries a 100-char
440 * excerpt of `comment_content`.
441 *
442 * @since 0.21.0
443 *
444 * @param WP_Comment $comment Trashed comment.
445 * @return array
446 */
447 function desktop_mode_recycle_bin_shape_comment_item( $comment ) {
448 $user_id = (int) get_comment_meta( $comment->comment_ID, '_desktop_mode_trash_user_id', true );
449 $deleted_at = (string) get_comment_meta( $comment->comment_ID, '_desktop_mode_trash_time_gmt', true );
450
451 if ( '' === $deleted_at ) {
452 $deleted_at = (string) $comment->comment_date_gmt;
453 }
454
455 $parent = $comment->comment_post_ID ? get_post( (int) $comment->comment_post_ID ) : null;
456 $parent_text = $parent ? get_the_title( $parent ) : '';
457 $author = $comment->comment_author
458 ? (string) $comment->comment_author
459 : __( 'Anonymous', 'desktop-mode' );
460
461 $title = '' !== $parent_text
462 ? sprintf(
463 /* translators: 1: comment author. 2: parent post title. */
464 __( '%1$s on %2$s', 'desktop-mode' ),
465 $author,
466 $parent_text
467 )
468 : $author;
469
470 $subtitle = wp_trim_words( wp_strip_all_tags( (string) $comment->comment_content ), 18, '' );
471
472 $user = $user_id ? get_userdata( $user_id ) : false;
473 $user_name = $user ? $user->display_name : '';
474
475 $item = array(
476 'id' => (int) $comment->comment_ID,
477 'type' => 'comment',
478 'type_label' => __( 'Comment', 'desktop-mode' ),
479 'title' => $title,
480 'subtitle' => $subtitle,
481 'mime' => '',
482 'preview' => '',
483 'icon' => 'dashicons-admin-comments',
484 'deleted_at' => $deleted_at,
485 'deleted_by' => $user_name,
486 'deleted_by_id' => $user_id,
487 'can_restore' => desktop_mode_recycle_bin_user_can_restore_comment( $comment ),
488 'can_purge' => desktop_mode_recycle_bin_user_can_purge_comment( $comment ),
489 'edit_link' => (string) get_edit_comment_link( $comment->comment_ID ),
490 );
491
492 /**
493 * Filter the comment item shape.
494 *
495 * @since 0.21.0
496 *
497 * @param array $item Item shape.
498 * @param WP_Comment $comment Source comment.
499 */
500 return (array) apply_filters( 'desktop_mode_recycle_bin_comment_item', $item, $comment );
501 }
502
503 /**
504 * Shape one WP_Post into the JSON the JS table consumes.
505 *
506 * @since 0.19.0
507 *
508 * @param WP_Post $post Trashed post.
509 * @return array
510 */
511 function desktop_mode_recycle_bin_shape_item( $post ) {
512 $user_id = (int) get_post_meta( $post->ID, '_desktop_mode_trash_user_id', true );
513 $deleted_at = (string) get_post_meta( $post->ID, '_desktop_mode_trash_time_gmt', true );
514
515 // Fall back to post_modified_gmt — set when wp_trash_post runs and
516 // reasonable for items captured before the recycle bin existed.
517 if ( '' === $deleted_at ) {
518 $deleted_at = (string) $post->post_modified_gmt;
519 }
520
521 $type = (string) $post->post_type;
522 $title = (string) get_the_title( $post );
523 $mime = (string) $post->post_mime_type;
524 $preview = '';
525 $icon = '';
526 $subtitle = '';
527
528 if ( 'attachment' === $type ) {
529 // Use the medium thumbnail when available, else core's default
530 // "broken image" placeholder. `wp_get_attachment_image_src()`
531 // returns false when the file is gone, so we always coerce.
532 $thumb = wp_get_attachment_image_src( $post->ID, array( 64, 64 ), true );
533 if ( is_array( $thumb ) ) {
534 $preview = (string) $thumb[0];
535 }
536 $icon = desktop_mode_recycle_bin_icon_for_mime( $mime );
537 $subtitle = $mime;
538 } elseif ( 'post' === $type ) {
539 $icon = 'dashicons-admin-post';
540 $subtitle = wp_trim_words( wp_strip_all_tags( (string) $post->post_excerpt ?: (string) $post->post_content ), 18, '' );
541 } elseif ( 'page' === $type ) {
542 $icon = 'dashicons-admin-page';
543 $subtitle = wp_trim_words( wp_strip_all_tags( (string) $post->post_content ), 18, '' );
544 } else {
545 $icon = 'dashicons-media-default';
546 }
547
548 $user = $user_id ? get_userdata( $user_id ) : false;
549 $user_name = $user ? $user->display_name : '';
550
551 // Resolve a human label for the type badge. `attachment` collapses
552 // to "Media" to match the toolbar filter; every other registered
553 // post type uses its singular label so CPTs read correctly (e.g.
554 // "Product" for WooCommerce). Unknown types fall back to a
555 // title-cased slug.
556 if ( 'attachment' === $type ) {
557 $type_label = __( 'Media', 'desktop-mode' );
558 } else {
559 $post_type_obj = get_post_type_object( $type );
560 if ( $post_type_obj && isset( $post_type_obj->labels->singular_name ) && '' !== (string) $post_type_obj->labels->singular_name ) {
561 $type_label = (string) $post_type_obj->labels->singular_name;
562 } else {
563 $type_label = ucwords( str_replace( array( '_', '-' ), ' ', $type ) );
564 }
565 }
566
567 $item = array(
568 'id' => (int) $post->ID,
569 'type' => $type,
570 'type_label' => $type_label,
571 'title' => '' !== $title ? $title : sprintf( '#%d', $post->ID ),
572 'subtitle' => $subtitle,
573 'mime' => $mime,
574 'preview' => $preview,
575 'icon' => $icon,
576 'deleted_at' => $deleted_at,
577 'deleted_by' => $user_name,
578 'deleted_by_id' => $user_id,
579 'can_restore' => desktop_mode_recycle_bin_user_can_restore( $post ),
580 'can_purge' => desktop_mode_recycle_bin_user_can_purge( $post ),
581 'edit_link' => (string) get_edit_post_link( $post->ID, 'raw' ),
582 );
583
584 /**
585 * Filter the item shape for the recycle bin table.
586 *
587 * Add custom columns or override the icon/preview for a custom
588 * post type. The id/type/deleted_at trio is load-bearing — keep
589 * them in the returned array.
590 *
591 * @since 0.19.0
592 *
593 * @param array $item Item shape.
594 * @param WP_Post $post Source post.
595 */
596 return (array) apply_filters( 'desktop_mode_recycle_bin_item', $item, $post );
597 }
598
599 /**
600 * Map a mime type to a Dashicon for the type cell.
601 *
602 * @since 0.19.0
603 *
604 * @param string $mime Mime type.
605 * @return string Dashicon class.
606 */
607 function desktop_mode_recycle_bin_icon_for_mime( $mime ) {
608 if ( '' === $mime ) {
609 return 'dashicons-media-default';
610 }
611 if ( str_starts_with( $mime, 'image/' ) ) {
612 return 'dashicons-format-image';
613 }
614 if ( str_starts_with( $mime, 'video/' ) ) {
615 return 'dashicons-format-video';
616 }
617 if ( str_starts_with( $mime, 'audio/' ) ) {
618 return 'dashicons-format-audio';
619 }
620 switch ( $mime ) {
621 case 'application/pdf':
622 return 'dashicons-pdf';
623 case 'application/zip':
624 case 'application/x-zip-compressed':
625 case 'application/x-tar':
626 case 'application/x-rar-compressed':
627 return 'dashicons-media-archive';
628 case 'text/plain':
629 case 'text/html':
630 case 'text/csv':
631 return 'dashicons-media-text';
632 case 'application/msword':
633 case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
634 return 'dashicons-media-document';
635 case 'application/vnd.ms-excel':
636 case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
637 return 'dashicons-media-spreadsheet';
638 case 'application/json':
639 return 'dashicons-media-code';
640 }
641 return 'dashicons-media-default';
642 }
643
644 /**
645 * Restore a single trashed item.
646 *
647 * Dispatches by `type`: comments go through `wp_untrash_comment`,
648 * everything else through `wp_untrash_post`. The legacy single-arg
649 * call (id only) defaults to `'post'` so older clients that haven't
650 * migrated to the typed API keep working.
651 *
652 * @since 0.19.0
653 * @since 0.21.0 Added `$type` parameter.
654 *
655 * @param int $id Post id (or comment id when `$type === 'comment'`).
656 * @param string $type Entity type — '', 'post', 'page', 'attachment', or 'comment'.
657 * @return true|WP_Error
658 */
659 function desktop_mode_recycle_bin_restore( $id, $type = '' ) {
660 $id = (int) $id;
661 if ( 'comment' === $type ) {
662 return desktop_mode_recycle_bin_restore_comment( $id );
663 }
664 if ( ( 'placement' === $type || 'shortcut' === $type ) && function_exists( 'desktop_mode_files_restore_placement' ) ) {
665 return desktop_mode_files_restore_placement( get_current_user_id(), $id );
666 }
667 if ( 'folder' === $type && function_exists( 'desktop_mode_files_restore_folder' ) ) {
668 return desktop_mode_files_restore_folder( get_current_user_id(), $id );
669 }
670
671 $post = get_post( $id );
672 if ( ! $post ) {
673 return new WP_Error( 'desktop_mode_recycle_bin_not_found', __( 'Item not found.', 'desktop-mode' ), array( 'status' => 404 ) );
674 }
675 if ( 'trash' !== $post->post_status ) {
676 return new WP_Error( 'desktop_mode_recycle_bin_not_trashed', __( 'Item is not in the trash.', 'desktop-mode' ), array( 'status' => 409 ) );
677 }
678 if ( ! desktop_mode_recycle_bin_user_can_restore( $post ) ) {
679 return new WP_Error( 'desktop_mode_recycle_bin_forbidden', __( 'You are not allowed to restore this item.', 'desktop-mode' ), array( 'status' => 403 ) );
680 }
681
682 /**
683 * Fires before a recycle-bin item is restored.
684 *
685 * @since 0.19.0
686 *
687 * @param int $id Post id about to be restored.
688 * @param WP_Post $post Trashed post object.
689 */
690 do_action( 'desktop_mode_recycle_bin_before_restore', $id, $post );
691
692 $ok = wp_untrash_post( $id );
693 if ( ! $ok ) {
694 return new WP_Error( 'desktop_mode_recycle_bin_restore_failed', __( 'Failed to restore item.', 'desktop-mode' ), array( 'status' => 500 ) );
695 }
696
697 delete_post_meta( $id, '_desktop_mode_trash_user_id' );
698 delete_post_meta( $id, '_desktop_mode_trash_time_gmt' );
699
700 /**
701 * Fires after a recycle-bin item is restored.
702 *
703 * @since 0.19.0
704 *
705 * @param int $id Post id that was restored.
706 */
707 do_action( 'desktop_mode_recycle_bin_after_restore', $id );
708
709 return true;
710 }
711
712 /**
713 * Restore a single trashed comment.
714 *
715 * @since 0.21.0
716 *
717 * @param int $comment_id Comment id.
718 * @return true|WP_Error
719 */
720 function desktop_mode_recycle_bin_restore_comment( $comment_id ) {
721 $comment_id = (int) $comment_id;
722 $comment = get_comment( $comment_id );
723
724 if ( ! $comment ) {
725 return new WP_Error( 'desktop_mode_recycle_bin_not_found', __( 'Comment not found.', 'desktop-mode' ), array( 'status' => 404 ) );
726 }
727 if ( 'trash' !== $comment->comment_approved ) {
728 return new WP_Error( 'desktop_mode_recycle_bin_not_trashed', __( 'Comment is not in the trash.', 'desktop-mode' ), array( 'status' => 409 ) );
729 }
730 if ( ! desktop_mode_recycle_bin_user_can_restore_comment( $comment ) ) {
731 return new WP_Error( 'desktop_mode_recycle_bin_forbidden', __( 'You are not allowed to restore this comment.', 'desktop-mode' ), array( 'status' => 403 ) );
732 }
733
734 /**
735 * Fires before a comment is restored from the recycle bin.
736 *
737 * @since 0.21.0
738 *
739 * @param int $comment_id Comment id.
740 * @param WP_Comment $comment Trashed comment.
741 */
742 do_action( 'desktop_mode_recycle_bin_before_restore_comment', $comment_id, $comment );
743
744 $ok = wp_untrash_comment( $comment_id );
745 if ( ! $ok ) {
746 return new WP_Error( 'desktop_mode_recycle_bin_restore_failed', __( 'Failed to restore comment.', 'desktop-mode' ), array( 'status' => 500 ) );
747 }
748
749 delete_comment_meta( $comment_id, '_desktop_mode_trash_user_id' );
750 delete_comment_meta( $comment_id, '_desktop_mode_trash_time_gmt' );
751
752 /**
753 * Fires after a comment is restored from the recycle bin.
754 *
755 * @since 0.21.0
756 *
757 * @param int $comment_id Comment id.
758 */
759 do_action( 'desktop_mode_recycle_bin_after_restore_comment', $comment_id );
760
761 return true;
762 }
763
764 /**
765 * Permanently delete a single trashed item. Dispatches by `$type`.
766 *
767 * @since 0.19.0
768 * @since 0.21.0 Added `$type` parameter.
769 *
770 * @param int $id Post id (or comment id when `$type === 'comment'`).
771 * @param string $type Entity type — '', 'post', 'page', 'attachment', or 'comment'.
772 * @return true|WP_Error
773 */
774 function desktop_mode_recycle_bin_purge( $id, $type = '' ) {
775 $id = (int) $id;
776 if ( 'comment' === $type ) {
777 return desktop_mode_recycle_bin_purge_comment( $id );
778 }
779 if ( ( 'placement' === $type || 'shortcut' === $type ) && function_exists( 'desktop_mode_files_purge_placement' ) ) {
780 return desktop_mode_files_purge_placement( get_current_user_id(), $id );
781 }
782 if ( 'folder' === $type && function_exists( 'desktop_mode_files_purge_folder' ) ) {
783 return desktop_mode_files_purge_folder( get_current_user_id(), $id );
784 }
785
786 $post = get_post( $id );
787 if ( ! $post ) {
788 return new WP_Error( 'desktop_mode_recycle_bin_not_found', __( 'Item not found.', 'desktop-mode' ), array( 'status' => 404 ) );
789 }
790 if ( 'trash' !== $post->post_status ) {
791 return new WP_Error( 'desktop_mode_recycle_bin_not_trashed', __( 'Item is not in the trash.', 'desktop-mode' ), array( 'status' => 409 ) );
792 }
793 if ( ! desktop_mode_recycle_bin_user_can_purge( $post ) ) {
794 return new WP_Error( 'desktop_mode_recycle_bin_forbidden', __( 'You are not allowed to permanently delete this item.', 'desktop-mode' ), array( 'status' => 403 ) );
795 }
796
797 /**
798 * Fires before a recycle-bin item is permanently deleted.
799 *
800 * @since 0.19.0
801 *
802 * @param int $id Post id about to be deleted.
803 * @param WP_Post $post Trashed post object.
804 */
805 do_action( 'desktop_mode_recycle_bin_before_purge', $id, $post );
806
807 if ( 'attachment' === $post->post_type ) {
808 // Force-delete bypasses our `pre_delete_attachment` interception
809 // (which would loop us back into trash) and removes the file.
810 $result = wp_delete_attachment( $id, true );
811 } else {
812 $result = wp_delete_post( $id, true );
813 }
814
815 if ( ! $result ) {
816 return new WP_Error( 'desktop_mode_recycle_bin_purge_failed', __( 'Failed to permanently delete item.', 'desktop-mode' ), array( 'status' => 500 ) );
817 }
818
819 /**
820 * Fires after a recycle-bin item is permanently deleted.
821 *
822 * @since 0.19.0
823 *
824 * @param int $id Post id that was purged.
825 * @param string $type Post type of the purged item.
826 */
827 do_action( 'desktop_mode_recycle_bin_after_purge', $id, $post->post_type );
828
829 return true;
830 }
831
832 /**
833 * Permanently delete a single trashed comment.
834 *
835 * @since 0.21.0
836 *
837 * @param int $comment_id Comment id.
838 * @return true|WP_Error
839 */
840 function desktop_mode_recycle_bin_purge_comment( $comment_id ) {
841 $comment_id = (int) $comment_id;
842 $comment = get_comment( $comment_id );
843
844 if ( ! $comment ) {
845 return new WP_Error( 'desktop_mode_recycle_bin_not_found', __( 'Comment not found.', 'desktop-mode' ), array( 'status' => 404 ) );
846 }
847 if ( 'trash' !== $comment->comment_approved ) {
848 return new WP_Error( 'desktop_mode_recycle_bin_not_trashed', __( 'Comment is not in the trash.', 'desktop-mode' ), array( 'status' => 409 ) );
849 }
850 if ( ! desktop_mode_recycle_bin_user_can_purge_comment( $comment ) ) {
851 return new WP_Error( 'desktop_mode_recycle_bin_forbidden', __( 'You are not allowed to permanently delete this comment.', 'desktop-mode' ), array( 'status' => 403 ) );
852 }
853
854 /**
855 * Fires before a comment is permanently deleted via the bin.
856 *
857 * @since 0.21.0
858 *
859 * @param int $comment_id Comment id.
860 * @param WP_Comment $comment Trashed comment.
861 */
862 do_action( 'desktop_mode_recycle_bin_before_purge_comment', $comment_id, $comment );
863
864 $result = wp_delete_comment( $comment_id, true );
865
866 if ( ! $result ) {
867 return new WP_Error( 'desktop_mode_recycle_bin_purge_failed', __( 'Failed to permanently delete comment.', 'desktop-mode' ), array( 'status' => 500 ) );
868 }
869
870 /**
871 * Fires after a comment is permanently deleted via the bin.
872 *
873 * @since 0.21.0
874 *
875 * @param int $comment_id Comment id.
876 */
877 do_action( 'desktop_mode_recycle_bin_after_purge_comment', $comment_id );
878
879 return true;
880 }
881
882 /**
883 * Empty the recycle bin for the current user.
884 *
885 * Honors the same capability gate as a single purge — items the user
886 * can't permanently delete are skipped (not silently dropped).
887 *
888 * Processes at most one chunk per call. The cap protects against PHP
889 * timeouts on large bins; the client iterates while `remaining > 0`
890 * (and bails when `remaining === skipped`, i.e. nothing the user can
891 * purge is left). Site owners with longer execution budgets can tune
892 * the chunk size via the `desktop_mode_recycle_bin_empty_chunk_size`
893 * filter.
894 *
895 * @since 0.19.0
896 *
897 * @return array {
898 * @type int $purged Items successfully purged in this call.
899 * @type int $skipped Items skipped (capability or error).
900 * @type int $remaining Items still in the bin after this call (across pages).
901 * }
902 */
903 function desktop_mode_recycle_bin_empty() {
904 $purged = 0;
905 $skipped = 0;
906
907 /**
908 * Filter the per-call chunk size for the empty-bin loop.
909 *
910 * `desktop_mode_recycle_bin_empty()` only purges this many items
911 * per invocation. The client iterates while `remaining > 0`. The
912 * default (200) is conservative for shared hosts; sites with
913 * generous PHP execution limits can raise it to make emptying a
914 * large bin take fewer roundtrips.
915 *
916 * @since 0.21.1
917 *
918 * @param int $chunk_size Items processed per call. Default 200.
919 */
920 $chunk_size = (int) apply_filters( 'desktop_mode_recycle_bin_empty_chunk_size', 200 );
921 if ( $chunk_size < 1 ) {
922 $chunk_size = 1;
923 }
924
925 // Loop in chunks — `wp_delete_post()` is cheap individually but
926 // hammering it on a 10k-item bin without yielding back to PHP can
927 // still time out. The client re-invokes us until `remaining` hits
928 // zero (or stalls at `skipped`).
929 $batch = desktop_mode_recycle_bin_get_items( array( 'per_page' => $chunk_size, 'page' => 1 ) );
930 foreach ( $batch['items'] as $item ) {
931 $result = desktop_mode_recycle_bin_purge(
932 (int) $item['id'],
933 (string) ( $item['type'] ?? '' )
934 );
935 if ( is_wp_error( $result ) ) {
936 ++$skipped;
937 } else {
938 ++$purged;
939 }
940 }
941
942 /**
943 * Fires after the recycle bin is emptied.
944 *
945 * @since 0.19.0
946 *
947 * @param int $purged Items successfully purged in this call.
948 * @param int $skipped Items skipped (capability or error).
949 */
950 do_action( 'desktop_mode_recycle_bin_emptied', $purged, $skipped );
951
952 return array(
953 'purged' => $purged,
954 'skipped' => $skipped,
955 'remaining' => max( 0, $batch['total'] - $purged ),
956 );
957 }
958