# desktop-mode/0.8.7/includes/recycle-bin/store.php

OpenStation: Desktop Windows, Dock &amp; Virtual Desktops for WP Admin, version 0.8.7. 958 lines.

- Page: https://pluginprobe.com/plugins/desktop-mode/0.8.7/code/includes/recycle-bin/store.php
- Raw: https://pluginprobe.com/plugins/desktop-mode/0.8.7/raw/includes/recycle-bin/store.php
- Modified: 2026-05-15T10:51:18+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/desktop-mode/0.8.7/code/includes/recycle-bin/store.php#L10-L20`.

```php
<?php
/**
 * Desktop Mode — Recycle Bin: store.
 *
 * Read/restore/purge primitives that the REST layer wraps. Backed
 * entirely by core post-table state — no custom tables, no options
 * blob. "Trashed" items are exactly the rows with
 * `post_status = 'trash'` for the post types the bin tracks.
 *
 * Every read goes through `desktop_mode_recycle_bin_query_args` so
 * plugins can scope the bin (e.g. show only the current user's
 * trash, or filter by author/role for compliance use cases).
 *
 * @package WPDesktopMode
 * @since   0.19.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * Returns the list of trashed items the current user is allowed to
 * see, shaped for the table component.
 *
 * @since 0.19.0
 *
 * @param array $args {
 *     Optional. Query overrides.
 *
 *     @type int    $per_page Default 100.
 *     @type int    $page     Default 1.
 *     @type string $type     One of '', 'post', 'page', 'attachment'.
 *     @type string $search   Free-text search over post_title.
 * }
 * @return array {
 *     @type array $items List of items shaped for the JS layer.
 *     @type int   $total Total matching rows (across pages).
 * }
 */
function desktop_mode_recycle_bin_get_items( $args = array() ) {
	$args = wp_parse_args(
		$args,
		array(
			'per_page' => 100,
			'page'     => 1,
			'type'     => '',
			'search'   => '',
		)
	);

	$type     = (string) $args['type'];
	$per_page = max( 1, (int) $args['per_page'] );

	// Two trash sources: posts (incl. pages and attachments) and
	// comments. Each is fetched independently then merged + sorted
	// by deleted-at desc — that way the bin reads as one chronological
	// timeline regardless of which entity was trashed.
	$items_posts    = array();
	$items_comments = array();

	// Source gates: each `$type` filter narrows down to the
	// owning store. `''` (All) loads every source. The files-on-
	// desktop sources (`shortcut` / `placement` / `folder`) live in
	// `desktop_mode_files_list_trashed_for_recycle_bin` — never run
	// the WP-core post / comment queries when one of those is the
	// active filter, otherwise trashed posts leak into the
	// "Shortcuts" / "Folders" tabs.
	$files_types      = array( 'desktop', 'placement', 'shortcut', 'folder' );
	$is_files_filter  = in_array( $type, $files_types, true );
	$wants_post_types = '' === $type
		|| ( 'comment' !== $type && ! $is_files_filter );
	$wants_comments   = ( '' === $type || 'comment' === $type )
		&& desktop_mode_recycle_bin_comments_enabled();

	if ( $wants_post_types ) {
		$post_types = desktop_mode_recycle_bin_capture_post_types();
		if ( '' !== $type && in_array( $type, $post_types, true ) ) {
			$post_types = array( $type );
		}

		$query_args = array(
			'post_type'        => $post_types,
			'post_status'      => 'trash',
			'posts_per_page'   => $per_page,
			'paged'            => max( 1, (int) $args['page'] ),
			'orderby'          => 'modified',
			'order'            => 'DESC',
			'suppress_filters' => false,
			's'                => (string) $args['search'],
		);

		/**
		 * Filter the WP_Query args used to populate the recycle bin.
		 *
		 * @since 0.19.0
		 *
		 * @param array $query_args Args passed to WP_Query.
		 * @param array $args       Caller-provided args.
		 */
		$query_args = apply_filters( 'desktop_mode_recycle_bin_query_args', $query_args, $args );

		$query = new WP_Query( $query_args );
		foreach ( $query->posts as $post ) {
			if ( ! desktop_mode_recycle_bin_user_can_view( $post ) ) {
				continue;
			}
			$items_posts[] = desktop_mode_recycle_bin_shape_item( $post );
		}
	}

	if ( $wants_comments ) {
		$comment_args = array(
			'status'  => 'trash',
			'number'  => $per_page,
			'orderby' => 'comment_date_gmt',
			'order'   => 'DESC',
		);
		if ( '' !== (string) $args['search'] ) {
			$comment_args['search'] = (string) $args['search'];
		}

		/**
		 * Filter the `WP_Comment_Query` args used to populate the
		 * recycle bin's comments. Mirror of
		 * `desktop_mode_recycle_bin_query_args` for comments.
		 *
		 * @since 0.21.0
		 *
		 * @param array $comment_args Args passed to `get_comments()`.
		 * @param array $args         Caller-provided args.
		 */
		$comment_args = apply_filters(
			'desktop_mode_recycle_bin_comment_query_args',
			$comment_args,
			$args
		);

		$comments = get_comments( $comment_args );
		if ( is_array( $comments ) ) {
			foreach ( $comments as $comment ) {
				if ( ! desktop_mode_recycle_bin_user_can_view_comment( $comment ) ) {
					continue;
				}
				$items_comments[] = desktop_mode_recycle_bin_shape_comment_item( $comment );
			}
		}
	}

	// Files-on-the-Desktop trash — soft-trashed placements
	// (shortcuts) and folders. Returned in the same item shape so
	// the JS layer treats them uniformly. The `placement` and
	// `folder` types route to the desktop-files trash module on
	// restore / purge (see `desktop_mode_recycle_bin_handle_files_*`).
	$items_files = array();
	// Map UI filter → set of `type` values to keep from the
	// files-on-desktop helper. The "Shortcuts" segment in the bin
	// UI now covers both registered icons (`shortcut`) AND user
	// folders (`folder`) — restore + purge dispatch still routes
	// each row by its individual type, so the merge is purely
	// visual.
	// "Desktop" is the unified bucket — every files-on-the-desktop
	// trash row regardless of internal type (shortcut / folder /
	// placement). Per-row dispatch on restore + purge still uses
	// the row's distinct `type` so the merge is purely visual.
	$wanted_files_types = array();
	switch ( $type ) {
		case '':
		case 'desktop':
			$wanted_files_types = array( 'shortcut', 'folder', 'placement' );
			break;
		case 'shortcut':
		case 'placement':
		case 'folder':
			$wanted_files_types = array( $type );
			break;
	}
	if (
		! empty( $wanted_files_types )
		&& function_exists( 'desktop_mode_files_list_trashed_for_recycle_bin' )
	) {
		$file_items = desktop_mode_files_list_trashed_for_recycle_bin(
			get_current_user_id()
		);
		foreach ( (array) $file_items as $item ) {
			if ( ! in_array( (string) $item['type'], $wanted_files_types, true ) ) {
				continue;
			}
			$items_files[] = $item;
		}
	}

	$items = array_merge( $items_posts, $items_comments, $items_files );

	// Sort the merged list chronologically by deleted_at desc. The
	// shape always carries a sortable string in `deleted_at`, so a
	// straight string compare is enough (`Y-m-d H:i:s` is sortable
	// lexicographically).
	usort( $items, static function ( $a, $b ) {
		return strcmp( (string) $b['deleted_at'], (string) $a['deleted_at'] );
	} );

	// `total` reports the GLOBAL trash count (every type, every
	// row, ignoring the current filter / search). The dock-tile
	// + desktop-icon badge consume this directly — `setRecycleBinBadge`
	// only cares about "how many things are sitting in the bin
	// right now". A future paginated UI that needs a filtered
	// count can compute it from `count( $items )` itself.
	$total = desktop_mode_recycle_bin_count();

	$offset = max( 0, ( max( 1, (int) $args['page'] ) - 1 ) * $per_page );
	$sliced = array_slice( $items, $offset, $per_page );

	/**
	 * Filter the final list of items returned to the JS layer.
	 *
	 * @since 0.19.0
	 *
	 * @param array      $items Shaped list (id, title, type, deleted_at, …).
	 * @param array|null $query Underlying post query, or null for the
	 *                          merged post+comment shape (since 0.21.0).
	 */
	$sliced = apply_filters( 'desktop_mode_recycle_bin_items', $sliced, null );

	return array(
		'items' => $sliced,
		'total' => $total,
	);
}

/**
 * Total number of items in the recycle bin, summed across every
 * tracked source (post types + comments).
 *
 * Cheaper than `desktop_mode_recycle_bin_get_items()` because it never
 * loads the row data — just the COUNT(*) under the hood. Used by
 * the badge on the dock tile + desktop icon, and by the REST
 * `/count` endpoint subscribers refresh on broadcasts.
 *
 * @since 0.21.0
 *
 * @return int
 */
function desktop_mode_recycle_bin_count() {
	$post_types = desktop_mode_recycle_bin_capture_post_types();

	$post_query = new WP_Query(
		array(
			'post_type'        => $post_types,
			'post_status'      => 'trash',
			'posts_per_page'   => 1,
			'fields'           => 'ids',
			'no_found_rows'    => false,
			'suppress_filters' => false,
		)
	);
	$post_count = (int) $post_query->found_posts;

	$comment_count = 0;
	if ( desktop_mode_recycle_bin_comments_enabled() ) {
		$comment_count = (int) get_comments(
			array(
				'status' => 'trash',
				'count'  => true,
			)
		);
	}

	$files_count = 0;
	if ( function_exists( 'desktop_mode_files_count_trashed_for_recycle_bin' ) ) {
		$files_count = (int) desktop_mode_files_count_trashed_for_recycle_bin( get_current_user_id() );
	}

	$total = $post_count + $comment_count + $files_count;

	/**
	 * Filter the total count surfaced to the badge.
	 *
	 * @since 0.21.0
	 *
	 * @param int $total         Default sum (posts + comments + files visible to the user).
	 * @param int $post_count    Items in trash from the post-type query.
	 * @param int $comment_count Items in trash from the comment query.
	 * @param int $files_count   Items in trash from the desktop-files trash (since 0.8.0).
	 */
	return (int) apply_filters( 'desktop_mode_recycle_bin_count', $total, $post_count, $comment_count, $files_count );
}

/**
 * Whether comments are part of the bin. Filterable so installs
 * that don't moderate comments at all (read-only blogs, headless
 * setups) can hide the segment without touching the JS.
 *
 * @since 0.21.0
 *
 * @return bool
 */
function desktop_mode_recycle_bin_comments_enabled() {
	$on = current_user_can( 'moderate_comments' );

	/**
	 * Filter whether the recycle bin tracks comments.
	 *
	 * @since 0.21.0
	 *
	 * @param bool $on Default: current user has `moderate_comments`.
	 */
	return (bool) apply_filters( 'desktop_mode_recycle_bin_comments_enabled', $on );
}

/**
 * Whether the current user can see a given trashed item.
 *
 * Mirrors `current_user_can( 'edit_post', $id )` for the consistent
 * "if you can edit it, you can manage its trash" rule. Filterable for
 * stricter / looser policies.
 *
 * @since 0.19.0
 *
 * @param WP_Post $post Trashed post.
 * @return bool
 */
function desktop_mode_recycle_bin_user_can_view( $post ) {
	$can = current_user_can( 'edit_post', $post->ID );

	/**
	 * Filter whether the current user can see a given trashed item.
	 *
	 * @since 0.19.0
	 *
	 * @param bool    $can  Default: edit_post capability check.
	 * @param WP_Post $post Trashed post.
	 */
	return (bool) apply_filters( 'desktop_mode_recycle_bin_user_can_view', $can, $post );
}

/**
 * Whether the current user can restore a given trashed item.
 *
 * @since 0.19.0
 *
 * @param WP_Post $post Trashed post.
 * @return bool
 */
function desktop_mode_recycle_bin_user_can_restore( $post ) {
	$can = current_user_can( 'delete_post', $post->ID );

	/**
	 * Filter whether the current user can restore a given trashed item.
	 *
	 * @since 0.19.0
	 *
	 * @param bool    $can  Default: delete_post capability check (the same
	 *                      gate WP itself uses for trash/untrash).
	 * @param WP_Post $post Trashed post.
	 */
	return (bool) apply_filters( 'desktop_mode_recycle_bin_user_can_restore', $can, $post );
}

/**
 * Whether the current user can permanently delete a trashed item.
 *
 * @since 0.19.0
 *
 * @param WP_Post $post Trashed post.
 * @return bool
 */
function desktop_mode_recycle_bin_user_can_purge( $post ) {
	$can = current_user_can( 'delete_post', $post->ID );

	/**
	 * Filter whether the current user can permanently delete a trashed item.
	 *
	 * @since 0.19.0
	 *
	 * @param bool    $can  Default: delete_post capability check.
	 * @param WP_Post $post Trashed post.
	 */
	return (bool) apply_filters( 'desktop_mode_recycle_bin_user_can_purge', $can, $post );
}

/**
 * Capability gates for trashed comments. Mirror of the post gates,
 * with `edit_comment`/`moderate_comments` as the WP-native checks.
 *
 * @since 0.21.0
 *
 * @param WP_Comment $comment Trashed comment.
 * @return bool
 */
function desktop_mode_recycle_bin_user_can_view_comment( $comment ) {
	$can = current_user_can( 'edit_comment', $comment->comment_ID );

	/**
	 * @since 0.21.0
	 * @param bool       $can     Default: edit_comment capability check.
	 * @param WP_Comment $comment Trashed comment.
	 */
	return (bool) apply_filters( 'desktop_mode_recycle_bin_user_can_view_comment', $can, $comment );
}

/**
 * @since 0.21.0
 *
 * @param WP_Comment $comment Trashed comment.
 * @return bool
 */
function desktop_mode_recycle_bin_user_can_restore_comment( $comment ) {
	$can = current_user_can( 'edit_comment', $comment->comment_ID );

	/**
	 * @since 0.21.0
	 * @param bool       $can     Default: edit_comment capability check.
	 * @param WP_Comment $comment Trashed comment.
	 */
	return (bool) apply_filters( 'desktop_mode_recycle_bin_user_can_restore_comment', $can, $comment );
}

/**
 * @since 0.21.0
 *
 * @param WP_Comment $comment Trashed comment.
 * @return bool
 */
function desktop_mode_recycle_bin_user_can_purge_comment( $comment ) {
	$can = current_user_can( 'edit_comment', $comment->comment_ID );

	/**
	 * @since 0.21.0
	 * @param bool       $can     Default: edit_comment capability check.
	 * @param WP_Comment $comment Trashed comment.
	 */
	return (bool) apply_filters( 'desktop_mode_recycle_bin_user_can_purge_comment', $can, $comment );
}

/**
 * Shape a `WP_Comment` into the JSON the JS table consumes.
 *
 * Same field set as the post shape so the React-style table doesn't
 * have to special-case the row by `type`. The `title` reads as
 * "<author> on <post title>"; the `subtitle` carries a 100-char
 * excerpt of `comment_content`.
 *
 * @since 0.21.0
 *
 * @param WP_Comment $comment Trashed comment.
 * @return array
 */
function desktop_mode_recycle_bin_shape_comment_item( $comment ) {
	$user_id    = (int) get_comment_meta( $comment->comment_ID, '_desktop_mode_trash_user_id', true );
	$deleted_at = (string) get_comment_meta( $comment->comment_ID, '_desktop_mode_trash_time_gmt', true );

	if ( '' === $deleted_at ) {
		$deleted_at = (string) $comment->comment_date_gmt;
	}

	$parent      = $comment->comment_post_ID ? get_post( (int) $comment->comment_post_ID ) : null;
	$parent_text = $parent ? get_the_title( $parent ) : '';
	$author      = $comment->comment_author
		? (string) $comment->comment_author
		: __( 'Anonymous', 'desktop-mode' );

	$title = '' !== $parent_text
		? sprintf(
			/* translators: 1: comment author. 2: parent post title. */
			__( '%1$s on %2$s', 'desktop-mode' ),
			$author,
			$parent_text
		)
		: $author;

	$subtitle = wp_trim_words( wp_strip_all_tags( (string) $comment->comment_content ), 18, '…' );

	$user      = $user_id ? get_userdata( $user_id ) : false;
	$user_name = $user ? $user->display_name : '';

	$item = array(
		'id'            => (int) $comment->comment_ID,
		'type'          => 'comment',
		'type_label'    => __( 'Comment', 'desktop-mode' ),
		'title'         => $title,
		'subtitle'      => $subtitle,
		'mime'          => '',
		'preview'       => '',
		'icon'          => 'dashicons-admin-comments',
		'deleted_at'    => $deleted_at,
		'deleted_by'    => $user_name,
		'deleted_by_id' => $user_id,
		'can_restore'   => desktop_mode_recycle_bin_user_can_restore_comment( $comment ),
		'can_purge'     => desktop_mode_recycle_bin_user_can_purge_comment( $comment ),
		'edit_link'     => (string) get_edit_comment_link( $comment->comment_ID ),
	);

	/**
	 * Filter the comment item shape.
	 *
	 * @since 0.21.0
	 *
	 * @param array      $item    Item shape.
	 * @param WP_Comment $comment Source comment.
	 */
	return (array) apply_filters( 'desktop_mode_recycle_bin_comment_item', $item, $comment );
}

/**
 * Shape one WP_Post into the JSON the JS table consumes.
 *
 * @since 0.19.0
 *
 * @param WP_Post $post Trashed post.
 * @return array
 */
function desktop_mode_recycle_bin_shape_item( $post ) {
	$user_id    = (int) get_post_meta( $post->ID, '_desktop_mode_trash_user_id', true );
	$deleted_at = (string) get_post_meta( $post->ID, '_desktop_mode_trash_time_gmt', true );

	// Fall back to post_modified_gmt — set when wp_trash_post runs and
	// reasonable for items captured before the recycle bin existed.
	if ( '' === $deleted_at ) {
		$deleted_at = (string) $post->post_modified_gmt;
	}

	$type     = (string) $post->post_type;
	$title    = (string) get_the_title( $post );
	$mime     = (string) $post->post_mime_type;
	$preview  = '';
	$icon     = '';
	$subtitle = '';

	if ( 'attachment' === $type ) {
		// Use the medium thumbnail when available, else core's default
		// "broken image" placeholder. `wp_get_attachment_image_src()`
		// returns false when the file is gone, so we always coerce.
		$thumb = wp_get_attachment_image_src( $post->ID, array( 64, 64 ), true );
		if ( is_array( $thumb ) ) {
			$preview = (string) $thumb[0];
		}
		$icon     = desktop_mode_recycle_bin_icon_for_mime( $mime );
		$subtitle = $mime;
	} elseif ( 'post' === $type ) {
		$icon     = 'dashicons-admin-post';
		$subtitle = wp_trim_words( wp_strip_all_tags( (string) $post->post_excerpt ?: (string) $post->post_content ), 18, '…' );
	} elseif ( 'page' === $type ) {
		$icon     = 'dashicons-admin-page';
		$subtitle = wp_trim_words( wp_strip_all_tags( (string) $post->post_content ), 18, '…' );
	} else {
		$icon = 'dashicons-media-default';
	}

	$user      = $user_id ? get_userdata( $user_id ) : false;
	$user_name = $user ? $user->display_name : '';

	// Resolve a human label for the type badge. `attachment` collapses
	// to "Media" to match the toolbar filter; every other registered
	// post type uses its singular label so CPTs read correctly (e.g.
	// "Product" for WooCommerce). Unknown types fall back to a
	// title-cased slug.
	if ( 'attachment' === $type ) {
		$type_label = __( 'Media', 'desktop-mode' );
	} else {
		$post_type_obj = get_post_type_object( $type );
		if ( $post_type_obj && isset( $post_type_obj->labels->singular_name ) && '' !== (string) $post_type_obj->labels->singular_name ) {
			$type_label = (string) $post_type_obj->labels->singular_name;
		} else {
			$type_label = ucwords( str_replace( array( '_', '-' ), ' ', $type ) );
		}
	}

	$item = array(
		'id'            => (int) $post->ID,
		'type'          => $type,
		'type_label'    => $type_label,
		'title'         => '' !== $title ? $title : sprintf( '#%d', $post->ID ),
		'subtitle'      => $subtitle,
		'mime'          => $mime,
		'preview'       => $preview,
		'icon'          => $icon,
		'deleted_at'    => $deleted_at,
		'deleted_by'    => $user_name,
		'deleted_by_id' => $user_id,
		'can_restore'   => desktop_mode_recycle_bin_user_can_restore( $post ),
		'can_purge'     => desktop_mode_recycle_bin_user_can_purge( $post ),
		'edit_link'     => (string) get_edit_post_link( $post->ID, 'raw' ),
	);

	/**
	 * Filter the item shape for the recycle bin table.
	 *
	 * Add custom columns or override the icon/preview for a custom
	 * post type. The id/type/deleted_at trio is load-bearing — keep
	 * them in the returned array.
	 *
	 * @since 0.19.0
	 *
	 * @param array   $item Item shape.
	 * @param WP_Post $post Source post.
	 */
	return (array) apply_filters( 'desktop_mode_recycle_bin_item', $item, $post );
}

/**
 * Map a mime type to a Dashicon for the type cell.
 *
 * @since 0.19.0
 *
 * @param string $mime Mime type.
 * @return string Dashicon class.
 */
function desktop_mode_recycle_bin_icon_for_mime( $mime ) {
	if ( '' === $mime ) {
		return 'dashicons-media-default';
	}
	if ( str_starts_with( $mime, 'image/' ) ) {
		return 'dashicons-format-image';
	}
	if ( str_starts_with( $mime, 'video/' ) ) {
		return 'dashicons-format-video';
	}
	if ( str_starts_with( $mime, 'audio/' ) ) {
		return 'dashicons-format-audio';
	}
	switch ( $mime ) {
		case 'application/pdf':
			return 'dashicons-pdf';
		case 'application/zip':
		case 'application/x-zip-compressed':
		case 'application/x-tar':
		case 'application/x-rar-compressed':
			return 'dashicons-media-archive';
		case 'text/plain':
		case 'text/html':
		case 'text/csv':
			return 'dashicons-media-text';
		case 'application/msword':
		case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
			return 'dashicons-media-document';
		case 'application/vnd.ms-excel':
		case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
			return 'dashicons-media-spreadsheet';
		case 'application/json':
			return 'dashicons-media-code';
	}
	return 'dashicons-media-default';
}

/**
 * Restore a single trashed item.
 *
 * Dispatches by `type`: comments go through `wp_untrash_comment`,
 * everything else through `wp_untrash_post`. The legacy single-arg
 * call (id only) defaults to `'post'` so older clients that haven't
 * migrated to the typed API keep working.
 *
 * @since 0.19.0
 * @since 0.21.0 Added `$type` parameter.
 *
 * @param int    $id   Post id (or comment id when `$type === 'comment'`).
 * @param string $type Entity type — '', 'post', 'page', 'attachment', or 'comment'.
 * @return true|WP_Error
 */
function desktop_mode_recycle_bin_restore( $id, $type = '' ) {
	$id = (int) $id;
	if ( 'comment' === $type ) {
		return desktop_mode_recycle_bin_restore_comment( $id );
	}
	if ( ( 'placement' === $type || 'shortcut' === $type ) && function_exists( 'desktop_mode_files_restore_placement' ) ) {
		return desktop_mode_files_restore_placement( get_current_user_id(), $id );
	}
	if ( 'folder' === $type && function_exists( 'desktop_mode_files_restore_folder' ) ) {
		return desktop_mode_files_restore_folder( get_current_user_id(), $id );
	}

	$post = get_post( $id );
	if ( ! $post ) {
		return new WP_Error( 'desktop_mode_recycle_bin_not_found', __( 'Item not found.', 'desktop-mode' ), array( 'status' => 404 ) );
	}
	if ( 'trash' !== $post->post_status ) {
		return new WP_Error( 'desktop_mode_recycle_bin_not_trashed', __( 'Item is not in the trash.', 'desktop-mode' ), array( 'status' => 409 ) );
	}
	if ( ! desktop_mode_recycle_bin_user_can_restore( $post ) ) {
		return new WP_Error( 'desktop_mode_recycle_bin_forbidden', __( 'You are not allowed to restore this item.', 'desktop-mode' ), array( 'status' => 403 ) );
	}

	/**
	 * Fires before a recycle-bin item is restored.
	 *
	 * @since 0.19.0
	 *
	 * @param int     $id   Post id about to be restored.
	 * @param WP_Post $post Trashed post object.
	 */
	do_action( 'desktop_mode_recycle_bin_before_restore', $id, $post );

	$ok = wp_untrash_post( $id );
	if ( ! $ok ) {
		return new WP_Error( 'desktop_mode_recycle_bin_restore_failed', __( 'Failed to restore item.', 'desktop-mode' ), array( 'status' => 500 ) );
	}

	delete_post_meta( $id, '_desktop_mode_trash_user_id' );
	delete_post_meta( $id, '_desktop_mode_trash_time_gmt' );

	/**
	 * Fires after a recycle-bin item is restored.
	 *
	 * @since 0.19.0
	 *
	 * @param int $id Post id that was restored.
	 */
	do_action( 'desktop_mode_recycle_bin_after_restore', $id );

	return true;
}

/**
 * Restore a single trashed comment.
 *
 * @since 0.21.0
 *
 * @param int $comment_id Comment id.
 * @return true|WP_Error
 */
function desktop_mode_recycle_bin_restore_comment( $comment_id ) {
	$comment_id = (int) $comment_id;
	$comment    = get_comment( $comment_id );

	if ( ! $comment ) {
		return new WP_Error( 'desktop_mode_recycle_bin_not_found', __( 'Comment not found.', 'desktop-mode' ), array( 'status' => 404 ) );
	}
	if ( 'trash' !== $comment->comment_approved ) {
		return new WP_Error( 'desktop_mode_recycle_bin_not_trashed', __( 'Comment is not in the trash.', 'desktop-mode' ), array( 'status' => 409 ) );
	}
	if ( ! desktop_mode_recycle_bin_user_can_restore_comment( $comment ) ) {
		return new WP_Error( 'desktop_mode_recycle_bin_forbidden', __( 'You are not allowed to restore this comment.', 'desktop-mode' ), array( 'status' => 403 ) );
	}

	/**
	 * Fires before a comment is restored from the recycle bin.
	 *
	 * @since 0.21.0
	 *
	 * @param int        $comment_id Comment id.
	 * @param WP_Comment $comment    Trashed comment.
	 */
	do_action( 'desktop_mode_recycle_bin_before_restore_comment', $comment_id, $comment );

	$ok = wp_untrash_comment( $comment_id );
	if ( ! $ok ) {
		return new WP_Error( 'desktop_mode_recycle_bin_restore_failed', __( 'Failed to restore comment.', 'desktop-mode' ), array( 'status' => 500 ) );
	}

	delete_comment_meta( $comment_id, '_desktop_mode_trash_user_id' );
	delete_comment_meta( $comment_id, '_desktop_mode_trash_time_gmt' );

	/**
	 * Fires after a comment is restored from the recycle bin.
	 *
	 * @since 0.21.0
	 *
	 * @param int $comment_id Comment id.
	 */
	do_action( 'desktop_mode_recycle_bin_after_restore_comment', $comment_id );

	return true;
}

/**
 * Permanently delete a single trashed item. Dispatches by `$type`.
 *
 * @since 0.19.0
 * @since 0.21.0 Added `$type` parameter.
 *
 * @param int    $id   Post id (or comment id when `$type === 'comment'`).
 * @param string $type Entity type — '', 'post', 'page', 'attachment', or 'comment'.
 * @return true|WP_Error
 */
function desktop_mode_recycle_bin_purge( $id, $type = '' ) {
	$id = (int) $id;
	if ( 'comment' === $type ) {
		return desktop_mode_recycle_bin_purge_comment( $id );
	}
	if ( ( 'placement' === $type || 'shortcut' === $type ) && function_exists( 'desktop_mode_files_purge_placement' ) ) {
		return desktop_mode_files_purge_placement( get_current_user_id(), $id );
	}
	if ( 'folder' === $type && function_exists( 'desktop_mode_files_purge_folder' ) ) {
		return desktop_mode_files_purge_folder( get_current_user_id(), $id );
	}

	$post = get_post( $id );
	if ( ! $post ) {
		return new WP_Error( 'desktop_mode_recycle_bin_not_found', __( 'Item not found.', 'desktop-mode' ), array( 'status' => 404 ) );
	}
	if ( 'trash' !== $post->post_status ) {
		return new WP_Error( 'desktop_mode_recycle_bin_not_trashed', __( 'Item is not in the trash.', 'desktop-mode' ), array( 'status' => 409 ) );
	}
	if ( ! desktop_mode_recycle_bin_user_can_purge( $post ) ) {
		return new WP_Error( 'desktop_mode_recycle_bin_forbidden', __( 'You are not allowed to permanently delete this item.', 'desktop-mode' ), array( 'status' => 403 ) );
	}

	/**
	 * Fires before a recycle-bin item is permanently deleted.
	 *
	 * @since 0.19.0
	 *
	 * @param int     $id   Post id about to be deleted.
	 * @param WP_Post $post Trashed post object.
	 */
	do_action( 'desktop_mode_recycle_bin_before_purge', $id, $post );

	if ( 'attachment' === $post->post_type ) {
		// Force-delete bypasses our `pre_delete_attachment` interception
		// (which would loop us back into trash) and removes the file.
		$result = wp_delete_attachment( $id, true );
	} else {
		$result = wp_delete_post( $id, true );
	}

	if ( ! $result ) {
		return new WP_Error( 'desktop_mode_recycle_bin_purge_failed', __( 'Failed to permanently delete item.', 'desktop-mode' ), array( 'status' => 500 ) );
	}

	/**
	 * Fires after a recycle-bin item is permanently deleted.
	 *
	 * @since 0.19.0
	 *
	 * @param int    $id   Post id that was purged.
	 * @param string $type Post type of the purged item.
	 */
	do_action( 'desktop_mode_recycle_bin_after_purge', $id, $post->post_type );

	return true;
}

/**
 * Permanently delete a single trashed comment.
 *
 * @since 0.21.0
 *
 * @param int $comment_id Comment id.
 * @return true|WP_Error
 */
function desktop_mode_recycle_bin_purge_comment( $comment_id ) {
	$comment_id = (int) $comment_id;
	$comment    = get_comment( $comment_id );

	if ( ! $comment ) {
		return new WP_Error( 'desktop_mode_recycle_bin_not_found', __( 'Comment not found.', 'desktop-mode' ), array( 'status' => 404 ) );
	}
	if ( 'trash' !== $comment->comment_approved ) {
		return new WP_Error( 'desktop_mode_recycle_bin_not_trashed', __( 'Comment is not in the trash.', 'desktop-mode' ), array( 'status' => 409 ) );
	}
	if ( ! desktop_mode_recycle_bin_user_can_purge_comment( $comment ) ) {
		return new WP_Error( 'desktop_mode_recycle_bin_forbidden', __( 'You are not allowed to permanently delete this comment.', 'desktop-mode' ), array( 'status' => 403 ) );
	}

	/**
	 * Fires before a comment is permanently deleted via the bin.
	 *
	 * @since 0.21.0
	 *
	 * @param int        $comment_id Comment id.
	 * @param WP_Comment $comment    Trashed comment.
	 */
	do_action( 'desktop_mode_recycle_bin_before_purge_comment', $comment_id, $comment );

	$result = wp_delete_comment( $comment_id, true );

	if ( ! $result ) {
		return new WP_Error( 'desktop_mode_recycle_bin_purge_failed', __( 'Failed to permanently delete comment.', 'desktop-mode' ), array( 'status' => 500 ) );
	}

	/**
	 * Fires after a comment is permanently deleted via the bin.
	 *
	 * @since 0.21.0
	 *
	 * @param int $comment_id Comment id.
	 */
	do_action( 'desktop_mode_recycle_bin_after_purge_comment', $comment_id );

	return true;
}

/**
 * Empty the recycle bin for the current user.
 *
 * Honors the same capability gate as a single purge — items the user
 * can't permanently delete are skipped (not silently dropped).
 *
 * Processes at most one chunk per call. The cap protects against PHP
 * timeouts on large bins; the client iterates while `remaining > 0`
 * (and bails when `remaining === skipped`, i.e. nothing the user can
 * purge is left). Site owners with longer execution budgets can tune
 * the chunk size via the `desktop_mode_recycle_bin_empty_chunk_size`
 * filter.
 *
 * @since 0.19.0
 *
 * @return array {
 *     @type int $purged    Items successfully purged in this call.
 *     @type int $skipped   Items skipped (capability or error).
 *     @type int $remaining Items still in the bin after this call (across pages).
 * }
 */
function desktop_mode_recycle_bin_empty() {
	$purged  = 0;
	$skipped = 0;

	/**
	 * Filter the per-call chunk size for the empty-bin loop.
	 *
	 * `desktop_mode_recycle_bin_empty()` only purges this many items
	 * per invocation. The client iterates while `remaining > 0`. The
	 * default (200) is conservative for shared hosts; sites with
	 * generous PHP execution limits can raise it to make emptying a
	 * large bin take fewer roundtrips.
	 *
	 * @since 0.21.1
	 *
	 * @param int $chunk_size Items processed per call. Default 200.
	 */
	$chunk_size = (int) apply_filters( 'desktop_mode_recycle_bin_empty_chunk_size', 200 );
	if ( $chunk_size < 1 ) {
		$chunk_size = 1;
	}

	// Loop in chunks — `wp_delete_post()` is cheap individually but
	// hammering it on a 10k-item bin without yielding back to PHP can
	// still time out. The client re-invokes us until `remaining` hits
	// zero (or stalls at `skipped`).
	$batch = desktop_mode_recycle_bin_get_items( array( 'per_page' => $chunk_size, 'page' => 1 ) );
	foreach ( $batch['items'] as $item ) {
		$result = desktop_mode_recycle_bin_purge(
			(int) $item['id'],
			(string) ( $item['type'] ?? '' )
		);
		if ( is_wp_error( $result ) ) {
			++$skipped;
		} else {
			++$purged;
		}
	}

	/**
	 * Fires after the recycle bin is emptied.
	 *
	 * @since 0.19.0
	 *
	 * @param int $purged  Items successfully purged in this call.
	 * @param int $skipped Items skipped (capability or error).
	 */
	do_action( 'desktop_mode_recycle_bin_emptied', $purged, $skipped );

	return array(
		'purged'    => $purged,
		'skipped'   => $skipped,
		'remaining' => max( 0, $batch['total'] - $purged ),
	);
}

```
