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

1,041 lines 33.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.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 ? desktop_mode_recycle_bin_plain_text( 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( desktop_mode_recycle_bin_plain_text( (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 * Collapse a title/subtitle to plain text for the wire.
550 *
551 * `get_the_title()` runs the `the_title` filter chain, and
552 * `wptexturize` in it encodes punctuation as numeric entities
553 * (apostrophe → `&#8217;`, quotes, dashes) — correct for HTML
554 * output, wrong for the bin table, which renders every cell via
555 * `textContent` and would show the literal entity. Strip tags first,
556 * then decode entities back to characters.
557 *
558 * @since 0.9.6
559 *
560 * @param string $text Raw filtered text.
561 * @return string
562 */
563 function desktop_mode_recycle_bin_plain_text( $text ) {
564 return html_entity_decode(
565 wp_strip_all_tags( (string) $text ),
566 ENT_QUOTES,
567 get_bloginfo( 'charset' )
568 );
569 }
570
571 /**
572 * Shape one WP_Post into the JSON the JS table consumes.
573 *
574 * @since 0.6.0
575 *
576 * @param WP_Post $post Trashed post.
577 * @return array
578 */
579 function desktop_mode_recycle_bin_shape_item( $post ) {
580 $user_id = (int) get_post_meta( $post->ID, '_desktop_mode_trash_user_id', true );
581 $deleted_at = (string) get_post_meta( $post->ID, '_desktop_mode_trash_time_gmt', true );
582
583 // Fall back to post_modified_gmt — set when wp_trash_post runs and
584 // reasonable for items captured before the recycle bin existed.
585 if ( '' === $deleted_at ) {
586 $deleted_at = (string) $post->post_modified_gmt;
587 }
588
589 $type = (string) $post->post_type;
590 $title = desktop_mode_recycle_bin_plain_text( (string) get_the_title( $post ) );
591 $mime = (string) $post->post_mime_type;
592 $preview = '';
593 $icon = '';
594 $subtitle = '';
595
596 if ( 'attachment' === $type ) {
597 // Use the medium thumbnail when available, else core's default
598 // "broken image" placeholder. `wp_get_attachment_image_src()`
599 // returns false when the file is gone, so we always coerce.
600 $thumb = wp_get_attachment_image_src( $post->ID, array( 64, 64 ), true );
601 if ( is_array( $thumb ) ) {
602 $preview = (string) $thumb[0];
603 }
604 $icon = desktop_mode_recycle_bin_icon_for_mime( $mime );
605 $subtitle = $mime;
606 } elseif ( 'post' === $type ) {
607 $icon = 'dashicons-admin-post';
608 $subtitle = wp_trim_words( desktop_mode_recycle_bin_plain_text( (string) $post->post_excerpt ?: (string) $post->post_content ), 18, '' );
609 } elseif ( 'page' === $type ) {
610 $icon = 'dashicons-admin-page';
611 $subtitle = wp_trim_words( desktop_mode_recycle_bin_plain_text( (string) $post->post_content ), 18, '' );
612 } else {
613 // Custom post types: reuse the type's own menu Dashicon when it
614 // registered one, so a trashed product row reads as a product
615 // instead of a generic file. Content excerpt as the subtitle,
616 // same as posts.
617 $icon = 'dashicons-media-default';
618 $post_type_obj = get_post_type_object( $type );
619 if (
620 $post_type_obj
621 && is_string( $post_type_obj->menu_icon )
622 && str_starts_with( $post_type_obj->menu_icon, 'dashicons-' )
623 ) {
624 $icon = $post_type_obj->menu_icon;
625 }
626 $subtitle = wp_trim_words( desktop_mode_recycle_bin_plain_text( (string) $post->post_excerpt ?: (string) $post->post_content ), 18, '' );
627 }
628
629 $user = $user_id ? get_userdata( $user_id ) : false;
630 $user_name = $user ? $user->display_name : '';
631
632 // Resolve a human label for the type badge. `attachment` collapses
633 // to "Media" to match the toolbar filter; every other registered
634 // post type uses its singular label so CPTs read correctly (e.g.
635 // "Product" for WooCommerce). Unknown types fall back to a
636 // title-cased slug.
637 if ( 'attachment' === $type ) {
638 $type_label = __( 'Media', 'desktop-mode' );
639 } else {
640 $post_type_obj = get_post_type_object( $type );
641 if ( $post_type_obj && isset( $post_type_obj->labels->singular_name ) && '' !== (string) $post_type_obj->labels->singular_name ) {
642 $type_label = (string) $post_type_obj->labels->singular_name;
643 } else {
644 $type_label = ucwords( str_replace( array( '_', '-' ), ' ', $type ) );
645 }
646 }
647
648 $item = array(
649 'id' => (int) $post->ID,
650 'type' => $type,
651 'type_label' => $type_label,
652 'title' => '' !== $title ? $title : sprintf( '#%d', $post->ID ),
653 'subtitle' => $subtitle,
654 'mime' => $mime,
655 'preview' => $preview,
656 'icon' => $icon,
657 'deleted_at' => $deleted_at,
658 'deleted_by' => $user_name,
659 'deleted_by_id' => $user_id,
660 'can_restore' => desktop_mode_recycle_bin_user_can_restore( $post ),
661 'can_purge' => desktop_mode_recycle_bin_user_can_purge( $post ),
662 'edit_link' => (string) get_edit_post_link( $post->ID, 'raw' ),
663 );
664
665 /**
666 * Filter the item shape for the recycle bin table.
667 *
668 * Add custom columns or override the icon/preview for a custom
669 * post type. The id/type/deleted_at trio is load-bearing — keep
670 * them in the returned array.
671 *
672 * @since 0.6.0
673 *
674 * @param array $item Item shape.
675 * @param WP_Post $post Source post.
676 */
677 return (array) apply_filters( 'desktop_mode_recycle_bin_item', $item, $post );
678 }
679
680 /**
681 * Map a mime type to a Dashicon for the type cell.
682 *
683 * @since 0.6.0
684 *
685 * @param string $mime Mime type.
686 * @return string Dashicon class.
687 */
688 function desktop_mode_recycle_bin_icon_for_mime( $mime ) {
689 if ( '' === $mime ) {
690 return 'dashicons-media-default';
691 }
692 if ( str_starts_with( $mime, 'image/' ) ) {
693 return 'dashicons-format-image';
694 }
695 if ( str_starts_with( $mime, 'video/' ) ) {
696 return 'dashicons-format-video';
697 }
698 if ( str_starts_with( $mime, 'audio/' ) ) {
699 return 'dashicons-format-audio';
700 }
701 switch ( $mime ) {
702 case 'application/pdf':
703 return 'dashicons-pdf';
704 case 'application/zip':
705 case 'application/x-zip-compressed':
706 case 'application/x-tar':
707 case 'application/x-rar-compressed':
708 return 'dashicons-media-archive';
709 case 'text/plain':
710 case 'text/html':
711 case 'text/csv':
712 return 'dashicons-media-text';
713 case 'application/msword':
714 case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
715 return 'dashicons-media-document';
716 case 'application/vnd.ms-excel':
717 case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
718 return 'dashicons-media-spreadsheet';
719 case 'application/json':
720 return 'dashicons-media-code';
721 }
722 return 'dashicons-media-default';
723 }
724
725 /**
726 * Restore a single trashed item.
727 *
728 * Dispatches by `type`: comments go through `wp_untrash_comment`,
729 * everything else through `wp_untrash_post`. The legacy single-arg
730 * call (id only) defaults to `'post'` so older clients that haven't
731 * migrated to the typed API keep working.
732 *
733 * @since 0.6.0
734 * @since 0.6.0 Added `$type` parameter.
735 *
736 * @param int $id Post id (or comment id when `$type === 'comment'`).
737 * @param string $type Entity type — '', 'post', 'page', 'attachment', or 'comment'.
738 * @return true|WP_Error
739 */
740 function desktop_mode_recycle_bin_restore( $id, $type = '' ) {
741 $id = (int) $id;
742 if ( 'comment' === $type ) {
743 return desktop_mode_recycle_bin_restore_comment( $id );
744 }
745 if ( ( 'placement' === $type || 'shortcut' === $type ) && function_exists( 'desktop_mode_files_restore_placement' ) ) {
746 return desktop_mode_files_restore_placement( get_current_user_id(), $id );
747 }
748 if ( 'folder' === $type && function_exists( 'desktop_mode_files_restore_folder' ) ) {
749 return desktop_mode_files_restore_folder( get_current_user_id(), $id );
750 }
751
752 $post = get_post( $id );
753 if ( ! $post ) {
754 return new WP_Error( 'desktop_mode_recycle_bin_not_found', __( 'Item not found.', 'desktop-mode' ), array( 'status' => 404 ) );
755 }
756 if ( 'trash' !== $post->post_status ) {
757 return new WP_Error( 'desktop_mode_recycle_bin_not_trashed', __( 'Item is not in the trash.', 'desktop-mode' ), array( 'status' => 409 ) );
758 }
759 if ( ! desktop_mode_recycle_bin_user_can_restore( $post ) ) {
760 return new WP_Error( 'desktop_mode_recycle_bin_forbidden', __( 'You are not allowed to restore this item.', 'desktop-mode' ), array( 'status' => 403 ) );
761 }
762
763 /**
764 * Fires before a recycle-bin item is restored.
765 *
766 * @since 0.6.0
767 *
768 * @param int $id Post id about to be restored.
769 * @param WP_Post $post Trashed post object.
770 */
771 do_action( 'desktop_mode_recycle_bin_before_restore', $id, $post );
772
773 $ok = wp_untrash_post( $id );
774 if ( ! $ok ) {
775 return new WP_Error( 'desktop_mode_recycle_bin_restore_failed', __( 'Failed to restore item.', 'desktop-mode' ), array( 'status' => 500 ) );
776 }
777
778 delete_post_meta( $id, '_desktop_mode_trash_user_id' );
779 delete_post_meta( $id, '_desktop_mode_trash_time_gmt' );
780
781 /**
782 * Fires after a recycle-bin item is restored.
783 *
784 * @since 0.6.0
785 *
786 * @param int $id Post id that was restored.
787 */
788 do_action( 'desktop_mode_recycle_bin_after_restore', $id );
789
790 return true;
791 }
792
793 /**
794 * Restore a single trashed comment.
795 *
796 * @since 0.6.0
797 *
798 * @param int $comment_id Comment id.
799 * @return true|WP_Error
800 */
801 function desktop_mode_recycle_bin_restore_comment( $comment_id ) {
802 $comment_id = (int) $comment_id;
803 $comment = get_comment( $comment_id );
804
805 if ( ! $comment ) {
806 return new WP_Error( 'desktop_mode_recycle_bin_not_found', __( 'Comment not found.', 'desktop-mode' ), array( 'status' => 404 ) );
807 }
808 if ( 'trash' !== $comment->comment_approved ) {
809 return new WP_Error( 'desktop_mode_recycle_bin_not_trashed', __( 'Comment is not in the trash.', 'desktop-mode' ), array( 'status' => 409 ) );
810 }
811 if ( ! desktop_mode_recycle_bin_user_can_restore_comment( $comment ) ) {
812 return new WP_Error( 'desktop_mode_recycle_bin_forbidden', __( 'You are not allowed to restore this comment.', 'desktop-mode' ), array( 'status' => 403 ) );
813 }
814
815 /**
816 * Fires before a comment is restored from the recycle bin.
817 *
818 * @since 0.6.0
819 *
820 * @param int $comment_id Comment id.
821 * @param WP_Comment $comment Trashed comment.
822 */
823 do_action( 'desktop_mode_recycle_bin_before_restore_comment', $comment_id, $comment );
824
825 $ok = wp_untrash_comment( $comment_id );
826 if ( ! $ok ) {
827 return new WP_Error( 'desktop_mode_recycle_bin_restore_failed', __( 'Failed to restore comment.', 'desktop-mode' ), array( 'status' => 500 ) );
828 }
829
830 delete_comment_meta( $comment_id, '_desktop_mode_trash_user_id' );
831 delete_comment_meta( $comment_id, '_desktop_mode_trash_time_gmt' );
832
833 /**
834 * Fires after a comment is restored from the recycle bin.
835 *
836 * @since 0.6.0
837 *
838 * @param int $comment_id Comment id.
839 */
840 do_action( 'desktop_mode_recycle_bin_after_restore_comment', $comment_id );
841
842 return true;
843 }
844
845 /**
846 * Permanently delete a single trashed item. Dispatches by `$type`.
847 *
848 * @since 0.6.0
849 * @since 0.6.0 Added `$type` parameter.
850 *
851 * @param int $id Post id (or comment id when `$type === 'comment'`).
852 * @param string $type Entity type — '', 'post', 'page', 'attachment', or 'comment'.
853 * @return true|WP_Error
854 */
855 function desktop_mode_recycle_bin_purge( $id, $type = '' ) {
856 $id = (int) $id;
857 if ( 'comment' === $type ) {
858 return desktop_mode_recycle_bin_purge_comment( $id );
859 }
860 if ( ( 'placement' === $type || 'shortcut' === $type ) && function_exists( 'desktop_mode_files_purge_placement' ) ) {
861 return desktop_mode_files_purge_placement( get_current_user_id(), $id );
862 }
863 if ( 'folder' === $type && function_exists( 'desktop_mode_files_purge_folder' ) ) {
864 return desktop_mode_files_purge_folder( get_current_user_id(), $id );
865 }
866
867 $post = get_post( $id );
868 if ( ! $post ) {
869 return new WP_Error( 'desktop_mode_recycle_bin_not_found', __( 'Item not found.', 'desktop-mode' ), array( 'status' => 404 ) );
870 }
871 if ( 'trash' !== $post->post_status ) {
872 return new WP_Error( 'desktop_mode_recycle_bin_not_trashed', __( 'Item is not in the trash.', 'desktop-mode' ), array( 'status' => 409 ) );
873 }
874 if ( ! desktop_mode_recycle_bin_user_can_purge( $post ) ) {
875 return new WP_Error( 'desktop_mode_recycle_bin_forbidden', __( 'You are not allowed to permanently delete this item.', 'desktop-mode' ), array( 'status' => 403 ) );
876 }
877
878 /**
879 * Fires before a recycle-bin item is permanently deleted.
880 *
881 * @since 0.6.0
882 *
883 * @param int $id Post id about to be deleted.
884 * @param WP_Post $post Trashed post object.
885 */
886 do_action( 'desktop_mode_recycle_bin_before_purge', $id, $post );
887
888 if ( 'attachment' === $post->post_type ) {
889 // Force-delete (`true`) removes the attachment and its file
890 // permanently. (The item is already trashed, so even with
891 // MEDIA_TRASH enabled core would not re-route it to trash;
892 // `true` simply makes the purge intent explicit.)
893 $result = wp_delete_attachment( $id, true );
894 } else {
895 $result = wp_delete_post( $id, true );
896 }
897
898 if ( ! $result ) {
899 return new WP_Error( 'desktop_mode_recycle_bin_purge_failed', __( 'Failed to permanently delete item.', 'desktop-mode' ), array( 'status' => 500 ) );
900 }
901
902 /**
903 * Fires after a recycle-bin item is permanently deleted.
904 *
905 * @since 0.6.0
906 *
907 * @param int $id Post id that was purged.
908 * @param string $type Post type of the purged item.
909 */
910 do_action( 'desktop_mode_recycle_bin_after_purge', $id, $post->post_type );
911
912 return true;
913 }
914
915 /**
916 * Permanently delete a single trashed comment.
917 *
918 * @since 0.6.0
919 *
920 * @param int $comment_id Comment id.
921 * @return true|WP_Error
922 */
923 function desktop_mode_recycle_bin_purge_comment( $comment_id ) {
924 $comment_id = (int) $comment_id;
925 $comment = get_comment( $comment_id );
926
927 if ( ! $comment ) {
928 return new WP_Error( 'desktop_mode_recycle_bin_not_found', __( 'Comment not found.', 'desktop-mode' ), array( 'status' => 404 ) );
929 }
930 if ( 'trash' !== $comment->comment_approved ) {
931 return new WP_Error( 'desktop_mode_recycle_bin_not_trashed', __( 'Comment is not in the trash.', 'desktop-mode' ), array( 'status' => 409 ) );
932 }
933 if ( ! desktop_mode_recycle_bin_user_can_purge_comment( $comment ) ) {
934 return new WP_Error( 'desktop_mode_recycle_bin_forbidden', __( 'You are not allowed to permanently delete this comment.', 'desktop-mode' ), array( 'status' => 403 ) );
935 }
936
937 /**
938 * Fires before a comment is permanently deleted via the bin.
939 *
940 * @since 0.6.0
941 *
942 * @param int $comment_id Comment id.
943 * @param WP_Comment $comment Trashed comment.
944 */
945 do_action( 'desktop_mode_recycle_bin_before_purge_comment', $comment_id, $comment );
946
947 $result = wp_delete_comment( $comment_id, true );
948
949 if ( ! $result ) {
950 return new WP_Error( 'desktop_mode_recycle_bin_purge_failed', __( 'Failed to permanently delete comment.', 'desktop-mode' ), array( 'status' => 500 ) );
951 }
952
953 /**
954 * Fires after a comment is permanently deleted via the bin.
955 *
956 * @since 0.6.0
957 *
958 * @param int $comment_id Comment id.
959 */
960 do_action( 'desktop_mode_recycle_bin_after_purge_comment', $comment_id );
961
962 return true;
963 }
964
965 /**
966 * Empty the recycle bin for the current user.
967 *
968 * Honors the same capability gate as a single purge — items the user
969 * can't permanently delete are skipped (not silently dropped).
970 *
971 * Processes at most one chunk per call. The cap protects against PHP
972 * timeouts on large bins; the client iterates while `remaining > 0`
973 * (and bails when `remaining === skipped`, i.e. nothing the user can
974 * purge is left). Site owners with longer execution budgets can tune
975 * the chunk size via the `desktop_mode_recycle_bin_empty_chunk_size`
976 * filter.
977 *
978 * @since 0.6.0
979 *
980 * @return array {
981 * @type int $purged Items successfully purged in this call.
982 * @type int $skipped Items skipped (capability or error).
983 * @type int $remaining Items still in the bin after this call (across pages).
984 * }
985 */
986 function desktop_mode_recycle_bin_empty() {
987 $purged = 0;
988 $skipped = 0;
989
990 /**
991 * Filter the per-call chunk size for the empty-bin loop.
992 *
993 * `desktop_mode_recycle_bin_empty()` only purges this many items
994 * per invocation. The client iterates while `remaining > 0`. The
995 * default (200) is conservative for shared hosts; sites with
996 * generous PHP execution limits can raise it to make emptying a
997 * large bin take fewer roundtrips.
998 *
999 * @since 0.8.0
1000 *
1001 * @param int $chunk_size Items processed per call. Default 200.
1002 */
1003 $chunk_size = (int) apply_filters( 'desktop_mode_recycle_bin_empty_chunk_size', 200 );
1004 if ( $chunk_size < 1 ) {
1005 $chunk_size = 1;
1006 }
1007
1008 // Loop in chunks — `wp_delete_post()` is cheap individually but
1009 // hammering it on a 10k-item bin without yielding back to PHP can
1010 // still time out. The client re-invokes us until `remaining` hits
1011 // zero (or stalls at `skipped`).
1012 $batch = desktop_mode_recycle_bin_get_items( array( 'per_page' => $chunk_size, 'page' => 1 ) );
1013 foreach ( $batch['items'] as $item ) {
1014 $result = desktop_mode_recycle_bin_purge(
1015 (int) $item['id'],
1016 (string) ( $item['type'] ?? '' )
1017 );
1018 if ( is_wp_error( $result ) ) {
1019 ++$skipped;
1020 } else {
1021 ++$purged;
1022 }
1023 }
1024
1025 /**
1026 * Fires after the recycle bin is emptied.
1027 *
1028 * @since 0.6.0
1029 *
1030 * @param int $purged Items successfully purged in this call.
1031 * @param int $skipped Items skipped (capability or error).
1032 */
1033 do_action( 'desktop_mode_recycle_bin_emptied', $purged, $skipped );
1034
1035 return array(
1036 'purged' => $purged,
1037 'skipped' => $skipped,
1038 'remaining' => max( 0, $batch['total'] - $purged ),
1039 );
1040 }
1041