0 ) && isset( $row['user_id'] ) && (int) $row['user_id'] === $user_id; /** * Filter whether the user can trash this placement. * * @since 0.8.0 * * @param bool $can Default: ownership match. * @param int $user_id Acting user. * @param array $row Placement row. */ return (bool) apply_filters( 'desktop_mode_files_user_can_trash_placement', $can, $user_id, $row ); } /** * Whether the given user can restore a trashed placement. * * @since 0.8.0 * * @param int $user_id Acting user. * @param array $row Placement row (already trashed). * @return bool */ function desktop_mode_files_user_can_restore_placement( $user_id, $row ) { $user_id = (int) $user_id; $can = ( $user_id > 0 ) && isset( $row['user_id'] ) && (int) $row['user_id'] === $user_id; /** * @since 0.8.0 * * @param bool $can * @param int $user_id * @param array $row */ return (bool) apply_filters( 'desktop_mode_files_user_can_restore_placement', $can, $user_id, $row ); } /** * Whether the given user can permanently purge a trashed placement. * * @since 0.8.0 */ function desktop_mode_files_user_can_purge_placement( $user_id, $row ) { $user_id = (int) $user_id; $can = ( $user_id > 0 ) && isset( $row['user_id'] ) && (int) $row['user_id'] === $user_id; /** * @since 0.8.0 * * @param bool $can * @param int $user_id * @param array $row */ return (bool) apply_filters( 'desktop_mode_files_user_can_purge_placement', $can, $user_id, $row ); } /** * Whether the given user can trash a folder. Default: folder owner. * * @since 0.8.0 */ function desktop_mode_files_user_can_trash_folder( $user_id, $row ) { $user_id = (int) $user_id; $can = ( $user_id > 0 ) && isset( $row['owner_id'] ) && (int) $row['owner_id'] === $user_id; /** * @since 0.8.0 * * @param bool $can * @param int $user_id * @param array $row */ return (bool) apply_filters( 'desktop_mode_files_user_can_trash_folder', $can, $user_id, $row ); } /** * Whether the given user can restore a trashed folder. * * @since 0.8.0 */ function desktop_mode_files_user_can_restore_folder( $user_id, $row ) { $user_id = (int) $user_id; $can = ( $user_id > 0 ) && isset( $row['owner_id'] ) && (int) $row['owner_id'] === $user_id; /** * @since 0.8.0 * * @param bool $can * @param int $user_id * @param array $row */ return (bool) apply_filters( 'desktop_mode_files_user_can_restore_folder', $can, $user_id, $row ); } /** * Whether the given user can permanently purge a trashed folder. * * @since 0.8.0 */ function desktop_mode_files_user_can_purge_folder( $user_id, $row ) { $user_id = (int) $user_id; $can = ( $user_id > 0 ) && isset( $row['owner_id'] ) && (int) $row['owner_id'] === $user_id; /** * @since 0.8.0 * * @param bool $can * @param int $user_id * @param array $row */ return (bool) apply_filters( 'desktop_mode_files_user_can_purge_folder', $can, $user_id, $row ); } /* ================================================================== * * Ancestry snapshot + resurrection. * * When a placement is soft-trashed we capture every folder in * its parent chain into a JSON blob on `placements.trashed_meta`. * Restoring later walks that chain top-down: folders that are * still alive are reused, trashed folders cascade-restore, and * hard-deleted folders are recreated (with new ids; the chain is * rewritten as it walks). The placement comes back at the same * visual position inside the (possibly resurrected) parent. * ================================================================== */ /** * Walk up `$parent_id` through the folders + placements tables and * return the parent chain root-first. * * Each entry shape: * * array( * 'folder_id' => int, * 'folder_name' => string, * 'folder_share_mode' => string, * 'folder_share_meta' => array|null, * 'folder_owner_id' => int, * 'placement_parent_id' => int, // parent of this folder's placement * 'placement_x' => int, * 'placement_y' => int, * ) * * Returns `[]` for a root-level placement (`$parent_id === 0`). * * @since 0.8.0 * * @param int $parent_id Immediate parent folder id. * @return array> */ function desktop_mode_files_capture_ancestry( $parent_id ) { global $wpdb; $tables = desktop_mode_files_table_names(); $chain = array(); $cursor = (int) $parent_id; $guard = 0; // depth-bound — defends against accidental cycles. while ( $cursor > 0 && $guard < 32 ) { ++$guard; $folder = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$tables['folders']} WHERE id = %d", $cursor ), ARRAY_A ); if ( ! $folder ) { break; } // The folder's "where I sit on the desktop tree" lives on // its placement row. Pick any active or trashed placement // of this folder — we just need its parent_id + (x, y). $placement = $wpdb->get_row( $wpdb->prepare( "SELECT parent_id, x, y FROM {$tables['placements']} WHERE file_type = 'folder' AND file_ref = %s ORDER BY id ASC LIMIT 1", (string) $folder['id'] ), ARRAY_A ); $share_meta_raw = isset( $folder['share_meta'] ) ? (string) $folder['share_meta'] : ''; $share_meta = '' !== $share_meta_raw ? json_decode( $share_meta_raw, true ) : null; $entry = array( 'folder_id' => (int) $folder['id'], 'folder_name' => (string) $folder['name'], 'folder_share_mode' => (string) $folder['share_mode'], 'folder_share_meta' => is_array( $share_meta ) ? $share_meta : null, 'folder_owner_id' => (int) $folder['owner_id'], 'placement_parent_id' => $placement ? (int) $placement['parent_id'] : 0, 'placement_x' => $placement ? (int) $placement['x'] : 0, 'placement_y' => $placement ? (int) $placement['y'] : 0, ); array_unshift( $chain, $entry ); // root-first. $cursor = $entry['placement_parent_id']; } return $chain; } /** * Walk an ancestry snapshot top-down and return the resolved * leaf folder id — every missing or trashed folder along the way * is resurrected. The map of `original_id => resolved_id` lets * downstream entries rewrite their `placement_parent_id` so a * deeper folder lands inside the correct (possibly recreated) * parent. * * @since 0.8.0 * * @param int $user_id Acting user (used as owner for any * recreated folder). * @param array $ancestry Root-first chain captured at trash time. * @return int Resolved leaf parent id (0 when the placement was * at desktop root). */ function desktop_mode_files_resurrect_ancestry( $user_id, $ancestry ) { if ( empty( $ancestry ) ) { return 0; } $user_id = (int) $user_id; $id_map = array(); // original_id => resolved_id. $resolved = 0; foreach ( $ancestry as $entry ) { $orig_id = (int) $entry['folder_id']; $orig_par = (int) $entry['placement_parent_id']; // Rewrite: if our snapshot's recorded parent was ALSO an // ancestor we recreated, use the new id. $resolved_parent = isset( $id_map[ $orig_par ] ) ? (int) $id_map[ $orig_par ] : $orig_par; $folder = desktop_mode_files_get_folder( $orig_id, true ); if ( $folder ) { // Folder still exists. If trashed, restore it (cascade // brings back its own children that were trashed via // folder cascade). if ( ! empty( $folder['trashed_at_ms'] ) ) { desktop_mode_files_restore_folder( $user_id, $orig_id ); } $id_map[ $orig_id ] = $orig_id; $resolved = $orig_id; continue; } // Folder is gone — recreate it and place it under the // resolved parent. Owner falls back to the acting user // when the original owner can't be inferred (shared- // folder edge case Phase 6 will revisit). $owner_id = (int) ( $entry['folder_owner_id'] ?: $user_id ); $new_id = desktop_mode_files_create_folder( $owner_id, array( 'name' => (string) $entry['folder_name'], 'share_mode' => (string) $entry['folder_share_mode'], 'share_meta' => $entry['folder_share_meta'], ) ); if ( is_wp_error( $new_id ) ) { // Fall back to root — restoring at the wrong place is // strictly better than failing the restore outright. $resolved = $resolved_parent; $id_map[ $orig_id ] = $resolved; continue; } // Place the recreated folder where the snapshot says. desktop_mode_files_place( $user_id, $resolved_parent, 'folder', (string) $new_id, array( 'x' => (int) $entry['placement_x'], 'y' => (int) $entry['placement_y'], ) ); $id_map[ $orig_id ] = (int) $new_id; $resolved = (int) $new_id; } return $resolved; } /* ================================================================== * * Placement: trash / restore / purge. * ================================================================== */ /** * Soft-trash a placement. Sets `trashed_at_ms`, `trashed_by`. Returns * `true` on success, `WP_Error` on permission failure / missing row. * * Idempotent: trashing an already-trashed placement is a no-op * success. * * @since 0.8.0 * * @param int $user_id Acting user. * @param int $placement_id Placement id. * @return true|WP_Error */ function desktop_mode_files_trash_placement( $user_id, $placement_id ) { global $wpdb; $user_id = (int) $user_id; $placement_id = (int) $placement_id; $tables = desktop_mode_files_table_names(); $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$tables['placements']} WHERE id = %d", $placement_id ), ARRAY_A ); if ( ! $row ) { return new WP_Error( 'desktop_mode_files_placement_not_found', __( 'Placement not found.', 'desktop-mode' ), array( 'status' => 404 ) ); } if ( null !== $row['trashed_at_ms'] && '' !== $row['trashed_at_ms'] ) { return true; } if ( ! desktop_mode_files_user_can_trash_placement( $user_id, $row ) ) { return new WP_Error( 'desktop_mode_files_forbidden', __( 'You do not have permission to trash this item.', 'desktop-mode' ), array( 'status' => 403 ) ); } /** * Fires before a placement is trashed. * * @since 0.8.0 * * @param int $placement_id Placement id. * @param int $user_id Acting user. * @param array $row Placement row. */ do_action( 'desktop_mode_files_before_trash_placement', $placement_id, $user_id, $row ); $now = desktop_mode_files_now_ms(); $ancestry = desktop_mode_files_capture_ancestry( (int) $row['parent_id'] ); $meta = wp_json_encode( array( 'ancestry' => $ancestry ) ); $result = $wpdb->update( $tables['placements'], array( 'trashed_at_ms' => $now, 'trashed_by' => $user_id, 'trashed_meta' => $meta, 'updated_at_ms' => $now, ), array( 'id' => $placement_id ), array( '%d', '%d', '%s', '%d' ), array( '%d' ) ); // `$wpdb->update` returns `false` on schema mismatch (e.g. the // migration didn't add the column the function writes to). The // REST layer would otherwise translate the silent no-op into a // 200 OK and the UI would show "moved to trash" with nothing // actually trashed. if ( false === $result ) { return new WP_Error( 'desktop_mode_files_trash_failed', isset( $wpdb->last_error ) && $wpdb->last_error ? (string) $wpdb->last_error : __( 'Failed to write trash row.', 'desktop-mode' ), array( 'status' => 500 ) ); } /** * Fires after a placement is trashed. * * @since 0.8.0 * * @param int $placement_id Placement id. * @param int $user_id Acting user. */ do_action( 'desktop_mode_files_after_trash_placement', $placement_id, $user_id ); return true; } /** * Restore a trashed placement back to its original folder + (x, y). * * @since 0.8.0 * * @param int $user_id Acting user. * @param int $placement_id Placement id. * @return true|WP_Error */ function desktop_mode_files_restore_placement( $user_id, $placement_id ) { global $wpdb; $user_id = (int) $user_id; $placement_id = (int) $placement_id; $tables = desktop_mode_files_table_names(); $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$tables['placements']} WHERE id = %d", $placement_id ), ARRAY_A ); if ( ! $row ) { return new WP_Error( 'desktop_mode_files_placement_not_found', __( 'Placement not found.', 'desktop-mode' ), array( 'status' => 404 ) ); } if ( null === $row['trashed_at_ms'] || '' === $row['trashed_at_ms'] ) { return true; // Already active — idempotent. } if ( ! desktop_mode_files_user_can_restore_placement( $user_id, $row ) ) { return new WP_Error( 'desktop_mode_files_forbidden', __( 'You do not have permission to restore this item.', 'desktop-mode' ), array( 'status' => 403 ) ); } // Resolve the parent folder. Three branches: // - parent is alive → reuse the same id // - parent is trashed → cascade-restore it (and rest of the // chain) before placing the leaf // - parent is gone → walk the captured ancestry and // recreate every missing folder in // the chain $original_parent_id = (int) $row['parent_id']; $resolved_parent_id = $original_parent_id; if ( $original_parent_id > 0 ) { $parent_alive = desktop_mode_files_get_folder( $original_parent_id, true ); if ( $parent_alive ) { if ( ! empty( $parent_alive['trashed_at_ms'] ) ) { // Cascade restore — reach into the snapshot the // folder itself stored at trash time so any chain // above it is also resurrected. $folder_restore = desktop_mode_files_restore_folder( $user_id, $original_parent_id ); if ( is_wp_error( $folder_restore ) ) { return $folder_restore; } } $resolved_parent_id = $original_parent_id; } else { // Hard-deleted parent — read the ancestry snapshot we // stored at trash time and resurrect the chain. $meta_raw = isset( $row['trashed_meta'] ) ? (string) $row['trashed_meta'] : ''; $decoded = '' !== $meta_raw ? json_decode( $meta_raw, true ) : null; $ancestry = ( is_array( $decoded ) && isset( $decoded['ancestry'] ) && is_array( $decoded['ancestry'] ) ) ? $decoded['ancestry'] : array(); $resolved_parent_id = desktop_mode_files_resurrect_ancestry( $user_id, $ancestry ); } } /** * Fires before a placement is restored. * * @since 0.8.0 * * @param int $placement_id * @param int $user_id * @param array $row */ do_action( 'desktop_mode_files_before_restore_placement', $placement_id, $user_id, $row ); $wpdb->update( $tables['placements'], array( 'parent_id' => $resolved_parent_id, 'trashed_at_ms' => null, 'trashed_by' => null, 'trashed_via_folder' => null, 'trashed_meta' => null, 'updated_at_ms' => desktop_mode_files_now_ms(), ), array( 'id' => $placement_id ), array( '%d', null, null, null, null, '%d' ), array( '%d' ) ); // Enforce the "tombstones never refer to alive rows" invariant: // a placement coming back to life must not carry lingering // tombstones from an earlier (reversible) removal. Without this, // every heartbeat tick would re-deliver those tombstones to the // client and the row would flicker off the desktop on each tick. desktop_mode_files_clear_tombstones_for( 'placement', $placement_id ); /** * @since 0.8.0 * * @param int $placement_id * @param int $user_id */ do_action( 'desktop_mode_files_after_restore_placement', $placement_id, $user_id ); return true; } /** * Permanently delete a trashed placement. * * @since 0.8.0 * * @param int $user_id * @param int $placement_id * @return true|WP_Error */ function desktop_mode_files_purge_placement( $user_id, $placement_id ) { global $wpdb; $user_id = (int) $user_id; $placement_id = (int) $placement_id; $tables = desktop_mode_files_table_names(); $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$tables['placements']} WHERE id = %d", $placement_id ), ARRAY_A ); if ( ! $row ) { return true; // Already gone — idempotent. } if ( ! desktop_mode_files_user_can_purge_placement( $user_id, $row ) ) { return new WP_Error( 'desktop_mode_files_forbidden', __( 'You do not have permission to delete this item.', 'desktop-mode' ), array( 'status' => 403 ) ); } /** * @since 0.8.0 * * @param int $placement_id * @param int $user_id * @param array $row */ do_action( 'desktop_mode_files_before_purge_placement', $placement_id, $user_id, $row ); $wpdb->delete( $tables['placements'], array( 'id' => $placement_id ), array( '%d' ) ); /** * @since 0.8.0 * * @param int $placement_id * @param int $user_id */ do_action( 'desktop_mode_files_after_purge_placement', $placement_id, $user_id ); return true; } /* ================================================================== * * Folder: trash / restore / purge (cascades to child placements). * ================================================================== */ /** * Soft-trash a folder. Cascades to every child placement (any * placement whose `parent_id = folder_id`), marking them with * `trashed_via_folder = folder_id` so a later restore brings back * the same set without time-window heuristics. * * Idempotent on already-trashed. * * @since 0.8.0 * * @param int $user_id * @param int $folder_id * @return true|WP_Error */ function desktop_mode_files_trash_folder( $user_id, $folder_id ) { global $wpdb; $user_id = (int) $user_id; $folder_id = (int) $folder_id; $tables = desktop_mode_files_table_names(); $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$tables['folders']} WHERE id = %d", $folder_id ), ARRAY_A ); if ( ! $row ) { return new WP_Error( 'desktop_mode_files_folder_not_found', __( 'Folder not found.', 'desktop-mode' ), array( 'status' => 404 ) ); } if ( null !== $row['trashed_at_ms'] && '' !== $row['trashed_at_ms'] ) { return true; } if ( ! desktop_mode_files_user_can_trash_folder( $user_id, $row ) ) { return new WP_Error( 'desktop_mode_files_forbidden', __( 'You do not have permission to trash this folder.', 'desktop-mode' ), array( 'status' => 403 ) ); } /** * @since 0.8.0 * * @param int $folder_id * @param int $user_id * @param array $row */ do_action( 'desktop_mode_files_before_trash_folder', $folder_id, $user_id, $row ); $now = desktop_mode_files_now_ms(); // Capture the folder's own placement-chain ancestry so a future // restore can resurrect any parent folders that got hard-deleted // while this one was sitting in trash. $folder_placement = $wpdb->get_row( $wpdb->prepare( "SELECT parent_id FROM {$tables['placements']} WHERE file_type = 'folder' AND file_ref = %s ORDER BY id ASC LIMIT 1", (string) $folder_id ), ARRAY_A ); $folder_ancestry = $folder_placement ? desktop_mode_files_capture_ancestry( (int) $folder_placement['parent_id'] ) : array(); $folder_meta = wp_json_encode( array( 'ancestry' => $folder_ancestry ) ); // Trash the folder row. $folder_update = $wpdb->update( $tables['folders'], array( 'trashed_at_ms' => $now, 'trashed_by' => $user_id, 'trashed_meta' => $folder_meta, 'updated_at_ms' => $now, ), array( 'id' => $folder_id ), array( '%d', '%d', '%s', '%d' ), array( '%d' ) ); if ( false === $folder_update ) { return new WP_Error( 'desktop_mode_files_trash_failed', isset( $wpdb->last_error ) && $wpdb->last_error ? (string) $wpdb->last_error : __( 'Failed to trash folder.', 'desktop-mode' ), array( 'status' => 500 ) ); } // Cascade to child placements that are still active. Mark // `trashed_via_folder` so the restore knows which children to // resurrect. Already-trashed children keep their state. // // Each child also gets its own ancestry snapshot so restoring // just one child later (after the parent folder was hard- // deleted) can still recreate the chain — same shape as a // direct trash. Captured per-row because every child shares // the same parent chain, so we compute once. $ancestry = desktop_mode_files_capture_ancestry( $folder_id ); $meta = wp_json_encode( array( 'ancestry' => $ancestry ) ); $wpdb->query( $wpdb->prepare( "UPDATE {$tables['placements']} SET trashed_at_ms = %d, trashed_by = %d, trashed_via_folder = %d, trashed_meta = %s, updated_at_ms = %d WHERE parent_id = %d AND trashed_at_ms IS NULL", $now, $user_id, $folder_id, $meta, $now, $folder_id ) ); // Cascade trash to nested folders too. Recurses one level via // IDs; deep folder trees iterate. $child_folder_ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$tables['folders']} f INNER JOIN {$tables['placements']} p ON p.file_type = 'folder' AND p.file_ref = CAST( f.id AS CHAR ) WHERE p.parent_id = %d AND f.trashed_at_ms IS NULL", $folder_id ) ); foreach ( (array) $child_folder_ids as $child_id ) { desktop_mode_files_trash_folder( $user_id, (int) $child_id ); } /** * @since 0.8.0 * * @param int $folder_id * @param int $user_id */ do_action( 'desktop_mode_files_after_trash_folder', $folder_id, $user_id ); return true; } /** * Restore a trashed folder + every placement that was trashed via * its cascade. Items that were trashed BEFORE the folder cascade * (i.e. `trashed_via_folder IS NULL`) stay in the recycle bin — * the user trashed them deliberately, separate from the folder. * * @since 0.8.0 * * @param int $user_id * @param int $folder_id * @return true|WP_Error */ function desktop_mode_files_restore_folder( $user_id, $folder_id ) { global $wpdb; $user_id = (int) $user_id; $folder_id = (int) $folder_id; $tables = desktop_mode_files_table_names(); $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$tables['folders']} WHERE id = %d", $folder_id ), ARRAY_A ); if ( ! $row ) { return new WP_Error( 'desktop_mode_files_folder_not_found', __( 'Folder not found.', 'desktop-mode' ), array( 'status' => 404 ) ); } if ( null === $row['trashed_at_ms'] || '' === $row['trashed_at_ms'] ) { return true; } if ( ! desktop_mode_files_user_can_restore_folder( $user_id, $row ) ) { return new WP_Error( 'desktop_mode_files_forbidden', __( 'You do not have permission to restore this folder.', 'desktop-mode' ), array( 'status' => 403 ) ); } /** * @since 0.8.0 * * @param int $folder_id * @param int $user_id * @param array $row */ do_action( 'desktop_mode_files_before_restore_folder', $folder_id, $user_id, $row ); $now = desktop_mode_files_now_ms(); // Snapshot nested folder ids BEFORE we null `trashed_via_folder` // on the placements — that column is the only stable link from // a child folder's placement back to the parent cascade. $nested_ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT CAST( p.file_ref AS UNSIGNED ) AS fid FROM {$tables['placements']} p WHERE p.file_type = 'folder' AND p.parent_id = %d AND p.trashed_via_folder = %d", $folder_id, $folder_id ) ); $wpdb->update( $tables['folders'], array( 'trashed_at_ms' => null, 'trashed_by' => null, 'trashed_meta' => null, 'updated_at_ms' => $now, ), array( 'id' => $folder_id ), array( null, null, null, '%d' ), array( '%d' ) ); // If the folder's own placement points at a parent_id that's // been hard-deleted in the meantime, resurrect the chain from // the snapshot taken at trash time. $meta_raw = isset( $row['trashed_meta'] ) ? (string) $row['trashed_meta'] : ''; $decoded = '' !== $meta_raw ? json_decode( $meta_raw, true ) : null; $ancestry = ( is_array( $decoded ) && isset( $decoded['ancestry'] ) && is_array( $decoded['ancestry'] ) ) ? $decoded['ancestry'] : array(); if ( ! empty( $ancestry ) ) { $folder_placement_row = $wpdb->get_row( $wpdb->prepare( "SELECT id, parent_id FROM {$tables['placements']} WHERE file_type = 'folder' AND file_ref = %s ORDER BY id ASC LIMIT 1", (string) $folder_id ), ARRAY_A ); if ( $folder_placement_row ) { $origin_parent = (int) $folder_placement_row['parent_id']; $alive = $origin_parent > 0 ? desktop_mode_files_get_folder( $origin_parent, true ) : null; if ( $origin_parent > 0 && ! $alive ) { $resolved = desktop_mode_files_resurrect_ancestry( $user_id, $ancestry ); $wpdb->update( $tables['placements'], array( 'parent_id' => $resolved, 'updated_at_ms' => $now, ), array( 'id' => (int) $folder_placement_row['id'] ), array( '%d', '%d' ), array( '%d' ) ); } } } // Restore placements that this folder's trash had cascaded. $wpdb->query( $wpdb->prepare( "UPDATE {$tables['placements']} SET trashed_at_ms = NULL, trashed_by = NULL, trashed_via_folder = NULL, trashed_meta = NULL, updated_at_ms = %d WHERE trashed_via_folder = %d", $now, $folder_id ) ); // Recursively restore nested folders captured in the snapshot. foreach ( (array) $nested_ids as $nid ) { desktop_mode_files_restore_folder( $user_id, (int) $nid ); } // Enforce the "tombstones never refer to alive rows" invariant // across the restored cohort: the folder itself, every cascade- // restored placement that lived inside it, and every nested // folder recursed into above already clears its own. Here we // scrub the FOLDER's own tombstones plus those of every cascade- // restored placement so a fresh heartbeat tick can't surface // them as `removed.*` against the now-alive rows. desktop_mode_files_clear_tombstones_for( 'folder', $folder_id ); $restored_placement_ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$tables['placements']} WHERE trashed_via_folder IS NULL AND ( parent_id = %d OR ( file_type = 'folder' AND file_ref = %s ) )", $folder_id, (string) $folder_id ) ); foreach ( (array) $restored_placement_ids as $rpid ) { desktop_mode_files_clear_tombstones_for( 'placement', (int) $rpid ); } /** * @since 0.8.0 * * @param int $folder_id * @param int $user_id */ do_action( 'desktop_mode_files_after_restore_folder', $folder_id, $user_id ); return true; } /** * Permanently delete a trashed folder and all its trashed-via- * cascade child placements. Independent placements that landed in * the trash separately stay there. * * @since 0.8.0 * * @param int $user_id * @param int $folder_id * @return true|WP_Error */ function desktop_mode_files_purge_folder( $user_id, $folder_id ) { global $wpdb; $user_id = (int) $user_id; $folder_id = (int) $folder_id; $tables = desktop_mode_files_table_names(); $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$tables['folders']} WHERE id = %d", $folder_id ), ARRAY_A ); if ( ! $row ) { return true; } if ( ! desktop_mode_files_user_can_purge_folder( $user_id, $row ) ) { return new WP_Error( 'desktop_mode_files_forbidden', __( 'You do not have permission to delete this folder.', 'desktop-mode' ), array( 'status' => 403 ) ); } /** * @since 0.8.0 * * @param int $folder_id * @param int $user_id * @param array $row */ do_action( 'desktop_mode_files_before_purge_folder', $folder_id, $user_id, $row ); // Cascade-revoke every share + per-user decision for the folder // BEFORE deleting the folder row. Without this, purge left // orphan `folder_shares` + `share_user_decisions` rows pointing // at a folder id that no longer exists — `compute_visible_folders` // would still join them, and the row leak grew with every // recycle-bin empty. Mirrors the same cleanup // `desktop_mode_files_delete_folder_recursive` does for the // "delete from desktop" path. $share_ids = (array) $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$tables['shares']} WHERE folder_id = %d", $folder_id ) ); if ( ! empty( $share_ids ) ) { $placeholders = implode( ',', array_fill( 0, count( $share_ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $wpdb->query( $wpdb->prepare( "DELETE FROM {$tables['decisions']} WHERE share_id IN ($placeholders)", $share_ids ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $wpdb->query( $wpdb->prepare( "DELETE FROM {$tables['shares']} WHERE id IN ($placeholders)", $share_ids ) ); } // Drop every placement that points AT this folder (recipients' // root tiles + the owner's own), with tombstones so connected // clients scrub the tile via the heartbeat. $pointing_ids = (array) $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$tables['placements']} WHERE file_type = 'folder' AND file_ref = %s", (string) $folder_id ) ); foreach ( $pointing_ids as $pid ) { desktop_mode_files_write_tombstone( 'placement', (int) $pid ); } if ( ! empty( $pointing_ids ) ) { $placeholders = implode( ',', array_fill( 0, count( $pointing_ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $wpdb->query( $wpdb->prepare( "DELETE FROM {$tables['placements']} WHERE id IN ($placeholders)", $pointing_ids ) ); } $wpdb->delete( $tables['placements'], array( 'trashed_via_folder' => $folder_id ), array( '%d' ) ); $wpdb->delete( $tables['folders'], array( 'id' => $folder_id ), array( '%d' ) ); /** * @since 0.8.0 * * @param int $folder_id * @param int $user_id */ do_action( 'desktop_mode_files_after_purge_folder', $folder_id, $user_id ); return true; } /* ================================================================== * * Recycle-bin list builder. * ================================================================== */ /** * Count of trashed placements + folders surfaced to the recycle bin * for `$user_id`. Mirrors `_list_trashed_for_recycle_bin`'s "skip * cascaded children" rule so the badge matches the visible list. * * @since 0.8.0 * * @param int $user_id Owner. * @return int */ function desktop_mode_files_count_trashed_for_recycle_bin( $user_id ) { global $wpdb; $user_id = (int) $user_id; if ( $user_id <= 0 ) { return 0; } $tables = desktop_mode_files_table_names(); $placements = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$tables['placements']} WHERE user_id = %d AND trashed_at_ms IS NOT NULL AND trashed_via_folder IS NULL", $user_id ) ); $folders = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$tables['folders']} WHERE owner_id = %d AND trashed_at_ms IS NOT NULL", $user_id ) ); return $placements + $folders; } /** * Return the trashed placements + folders for a user, shaped as * recycle-bin items. Used by the recycle bin's REST list endpoint * to merge files-on-the-desktop trash with the WP-core trash. * * @since 0.8.0 * * @param int $user_id Owner. * @return array[] List of recycle-bin item shapes. */ function desktop_mode_files_list_trashed_for_recycle_bin( $user_id ) { global $wpdb; $user_id = (int) $user_id; $tables = desktop_mode_files_table_names(); $out = array(); // Trashed placements owned by this user. $placements = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$tables['placements']} WHERE user_id = %d AND trashed_at_ms IS NOT NULL ORDER BY trashed_at_ms DESC", $user_id ), ARRAY_A ); foreach ( (array) $placements as $row ) { // Skip cascaded children — the parent folder represents // the whole bundle in the recycle bin. if ( ! empty( $row['trashed_via_folder'] ) ) { continue; } $file = function_exists( 'desktop_mode_resolve_file' ) ? desktop_mode_resolve_file( $row['file_type'], $row['file_ref'] ) : null; $title = $file ? (string) $file->title() : (string) $row['file_type']; $icon = $file ? (string) $file->icon() : 'dashicons-no-alt'; // Two recycle-bin buckets: // - `shortcut` → plugin-registered icons (file_type='shortcut') // - `placement` → every other placement (post / page / // attachment / user / term / comment / …) // Lets the bin's type-filter tabs split "Shortcuts" from // "Files" without overloading either label. $bucket = ( 'shortcut' === (string) $row['file_type'] ) ? 'shortcut' : 'placement'; $subtitle = ( 'shortcut' === $bucket ) ? __( 'Desktop shortcut', 'desktop-mode' ) : sprintf( /* translators: %s: file-type slug like 'post', 'attachment'. */ __( '%s on desktop', 'desktop-mode' ), (string) $row['file_type'] ); // `type_label` is the short uppercase badge the JS renders // inline before the title. Most placements collapse to the // generic "Placement" badge (the JS humanizes the bucket // slug when no label is set). `link` placements — created // via "New URL" on the desktop — deserve a more specific // label so they read as URL-shortcuts, not generic tiles. $item = array( 'id' => (int) $row['id'], 'type' => $bucket, 'title' => $title, 'subtitle' => $subtitle, 'mime' => '', 'preview' => $file ? (string) $file->preview_url() : '', 'icon' => $icon, 'deleted_at' => gmdate( 'c', (int) round( (int) $row['trashed_at_ms'] / 1000 ) ), 'deleted_by' => '', 'deleted_by_id' => (int) $row['trashed_by'], 'can_restore' => desktop_mode_files_user_can_restore_placement( $user_id, $row ), 'can_purge' => desktop_mode_files_user_can_purge_placement( $user_id, $row ), 'edit_link' => '', ); if ( 'link' === (string) $row['file_type'] ) { $item['type_label'] = __( 'URL', 'desktop-mode' ); } $out[] = $item; } // Trashed folders owned by this user. $folders = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$tables['folders']} WHERE owner_id = %d AND trashed_at_ms IS NOT NULL ORDER BY trashed_at_ms DESC", $user_id ), ARRAY_A ); foreach ( (array) $folders as $row ) { $child_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$tables['placements']} WHERE trashed_via_folder = %d", (int) $row['id'] ) ); $out[] = array( 'id' => (int) $row['id'], 'type' => 'folder', 'title' => (string) $row['name'], 'subtitle' => $child_count > 0 ? sprintf( /* translators: %d: number of items inside the trashed folder. */ _n( 'Folder · %d item inside', 'Folder · %d items inside', $child_count, 'desktop-mode' ), $child_count ) : __( 'Folder · empty', 'desktop-mode' ), 'mime' => '', 'preview' => '', 'icon' => 'dashicons-portfolio', 'deleted_at' => gmdate( 'c', (int) round( (int) $row['trashed_at_ms'] / 1000 ) ), 'deleted_by' => '', 'deleted_by_id' => (int) $row['trashed_by'], 'can_restore' => desktop_mode_files_user_can_restore_folder( $user_id, $row ), 'can_purge' => desktop_mode_files_user_can_purge_folder( $user_id, $row ), 'edit_link' => '', ); } // Resolve display-name for the deleted-by id once per user. $user_cache = array(); foreach ( $out as &$item ) { $uid = (int) $item['deleted_by_id']; if ( $uid <= 0 ) { continue; } if ( ! isset( $user_cache[ $uid ] ) ) { $u = get_userdata( $uid ); $user_cache[ $uid ] = $u ? $u->display_name : ''; } $item['deleted_by'] = $user_cache[ $uid ]; } unset( $item ); return $out; }