PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
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 / desktop-files / trash.php

trash.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.8, at includes/desktop-files/trash.php

1,213 lines 37.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Files-on-the-Desktop trash + restore + purge.
4 *
5 * Both placements and folders soft-trash before they ever hit the
6 * physical row delete. Trashed rows live in the same tables (with
7 * `trashed_at_ms` / `trashed_by` columns set; `trashed_via_folder`
8 * on placements when the trash cascaded from a folder), so:
9 *
10 * - Active queries always filter `trashed_at_ms IS NULL`.
11 * - The recycle bin lists `trashed_at_ms IS NOT NULL`.
12 * - Restore is a single column flip; no row resurrection.
13 * - Folder restore brings back its trashed-via-cascade children
14 * by their `trashed_via_folder` marker, so the original layout
15 * is preserved with no fuzzy time-window heuristics.
16 *
17 * Every public function gates on a permission filter and emits
18 * before/after actions. Plugins can:
19 *
20 * - Veto any trash / restore / purge (`*_user_can_*` filters).
21 * - Observe any state transition (`*_before_*` / `*_after_*`).
22 * - React to recycle-bin list / restore / purge of the new types
23 * via the existing recycle-bin hooks (`desktop_mode_recycle_bin_*`).
24 *
25 * @package WPDesktopMode
26 */
27
28 defined( 'ABSPATH' ) || exit;
29
30 /* ================================================================== *
31 * Capability gates.
32 * ================================================================== */
33
34 /**
35 * Default ownership check shared by every trash / restore / purge
36 * capability gate: the acting user must be the row's `owner_id`.
37 *
38 * @access private
39 *
40 * @param int $user_id Acting user.
41 * @param array $row Placement or folder row.
42 * @return bool
43 */
44 function desktop_mode_files_user_owns_row( $user_id, $row ) {
45 $user_id = (int) $user_id;
46 return ( $user_id > 0 )
47 && isset( $row['owner_id'] )
48 && (int) $row['owner_id'] === $user_id;
49 }
50
51 /**
52 * Whether the given user can trash a placement they own. Defaults
53 * to ownership; plugins can broaden via filter.
54 *
55 * @param int $user_id Acting user.
56 * @param array $row Placement row (raw from DB or normalized).
57 * @return bool
58 */
59 function desktop_mode_files_user_can_trash_placement( $user_id, $row ) {
60 /**
61 * Filter whether the user can trash this placement.
62 *
63 * @param bool $can Default: ownership match.
64 * @param int $user_id Acting user.
65 * @param array $row Placement row.
66 */
67 return (bool) apply_filters(
68 'desktop_mode_files_user_can_trash_placement',
69 desktop_mode_files_user_owns_row( $user_id, $row ),
70 (int) $user_id,
71 $row
72 );
73 }
74
75 /**
76 * Whether the given user can restore a trashed placement.
77 *
78 * @param int $user_id Acting user.
79 * @param array $row Placement row (already trashed).
80 * @return bool
81 */
82 function desktop_mode_files_user_can_restore_placement( $user_id, $row ) {
83 /**
84 * @param bool $can
85 * @param int $user_id
86 * @param array $row
87 */
88 return (bool) apply_filters(
89 'desktop_mode_files_user_can_restore_placement',
90 desktop_mode_files_user_owns_row( $user_id, $row ),
91 (int) $user_id,
92 $row
93 );
94 }
95
96 /**
97 * Whether the given user can permanently purge a trashed placement.
98 */
99 function desktop_mode_files_user_can_purge_placement( $user_id, $row ) {
100 /**
101 * @param bool $can
102 * @param int $user_id
103 * @param array $row
104 */
105 return (bool) apply_filters(
106 'desktop_mode_files_user_can_purge_placement',
107 desktop_mode_files_user_owns_row( $user_id, $row ),
108 (int) $user_id,
109 $row
110 );
111 }
112
113 /**
114 * Whether the given user can trash a folder. Default: folder owner.
115 */
116 function desktop_mode_files_user_can_trash_folder( $user_id, $row ) {
117 /**
118 * @param bool $can
119 * @param int $user_id
120 * @param array $row
121 */
122 return (bool) apply_filters(
123 'desktop_mode_files_user_can_trash_folder',
124 desktop_mode_files_user_owns_row( $user_id, $row ),
125 (int) $user_id,
126 $row
127 );
128 }
129
130 /**
131 * Whether the given user can restore a trashed folder.
132 */
133 function desktop_mode_files_user_can_restore_folder( $user_id, $row ) {
134 /**
135 * @param bool $can
136 * @param int $user_id
137 * @param array $row
138 */
139 return (bool) apply_filters(
140 'desktop_mode_files_user_can_restore_folder',
141 desktop_mode_files_user_owns_row( $user_id, $row ),
142 (int) $user_id,
143 $row
144 );
145 }
146
147 /**
148 * Whether the given user can permanently purge a trashed folder.
149 */
150 function desktop_mode_files_user_can_purge_folder( $user_id, $row ) {
151 /**
152 * @param bool $can
153 * @param int $user_id
154 * @param array $row
155 */
156 return (bool) apply_filters(
157 'desktop_mode_files_user_can_purge_folder',
158 desktop_mode_files_user_owns_row( $user_id, $row ),
159 (int) $user_id,
160 $row
161 );
162 }
163
164 /* ================================================================== *
165 * Ancestry snapshot + resurrection.
166 *
167 * When a placement is soft-trashed we capture every folder in
168 * its parent chain into a JSON blob on `placements.trashed_meta`.
169 * Restoring later walks that chain top-down: folders that are
170 * still alive are reused, trashed folders cascade-restore, and
171 * hard-deleted folders are recreated (with new ids; the chain is
172 * rewritten as it walks). The placement comes back at the same
173 * visual position inside the (possibly resurrected) parent.
174 * ================================================================== */
175
176 /**
177 * Walk up `$parent_id` through the folders + placements tables and
178 * return the parent chain root-first.
179 *
180 * Each entry shape:
181 *
182 * array(
183 * 'folder_id' => int,
184 * 'folder_name' => string,
185 * 'folder_share_mode' => string,
186 * 'folder_share_meta' => array|null,
187 * 'folder_owner_id' => int,
188 * 'placement_parent_id' => int, // parent of this folder's placement
189 * 'placement_x' => int,
190 * 'placement_y' => int,
191 * )
192 *
193 * Returns `[]` for a root-level placement (`$parent_id === 0`).
194 *
195 * @param int $parent_id Immediate parent folder id.
196 * @return array<int, array<string, mixed>>
197 */
198 function desktop_mode_files_capture_ancestry( $parent_id ) {
199 global $wpdb;
200 $tables = desktop_mode_files_table_names();
201 $chain = array();
202 $cursor = (int) $parent_id;
203 $guard = 0; // depth-bound — defends against accidental cycles.
204 while ( $cursor > 0 && $guard < 32 ) {
205 ++$guard;
206 $folder = $wpdb->get_row(
207 $wpdb->prepare(
208 "SELECT * FROM {$tables['folders']} WHERE id = %d",
209 $cursor
210 ),
211 ARRAY_A
212 );
213 if ( ! $folder ) {
214 break;
215 }
216 // The folder's "where I sit on the desktop tree" lives on
217 // its placement row. Pick any active or trashed placement
218 // of this folder — we just need its parent_id + (x, y).
219 $placement = $wpdb->get_row(
220 $wpdb->prepare(
221 "SELECT parent_id, x, y FROM {$tables['placements']}
222 WHERE file_type = 'folder' AND file_ref = %s
223 ORDER BY id ASC LIMIT 1",
224 (string) $folder['id']
225 ),
226 ARRAY_A
227 );
228 $share_meta_raw = isset( $folder['share_meta'] ) ? (string) $folder['share_meta'] : '';
229 $share_meta = '' !== $share_meta_raw ? json_decode( $share_meta_raw, true ) : null;
230 $entry = array(
231 'folder_id' => (int) $folder['id'],
232 'folder_name' => (string) $folder['name'],
233 'folder_share_mode' => (string) $folder['share_mode'],
234 'folder_share_meta' => is_array( $share_meta ) ? $share_meta : null,
235 'folder_owner_id' => (int) $folder['owner_id'],
236 'placement_parent_id' => $placement ? (int) $placement['parent_id'] : 0,
237 'placement_x' => $placement ? (int) $placement['x'] : 0,
238 'placement_y' => $placement ? (int) $placement['y'] : 0,
239 );
240 array_unshift( $chain, $entry ); // root-first.
241 $cursor = $entry['placement_parent_id'];
242 }
243 return $chain;
244 }
245
246 /**
247 * Walk an ancestry snapshot top-down and return the resolved
248 * leaf folder id — every missing or trashed folder along the way
249 * is resurrected. The map of `original_id => resolved_id` lets
250 * downstream entries rewrite their `placement_parent_id` so a
251 * deeper folder lands inside the correct (possibly recreated)
252 * parent.
253 *
254 * @param int $user_id Acting user (used as owner for any
255 * recreated folder).
256 * @param array $ancestry Root-first chain captured at trash time.
257 * @return int Resolved leaf parent id (0 when the placement was
258 * at desktop root).
259 */
260 function desktop_mode_files_resurrect_ancestry( $user_id, $ancestry ) {
261 if ( empty( $ancestry ) ) {
262 return 0;
263 }
264 $user_id = (int) $user_id;
265 $id_map = array(); // original_id => resolved_id.
266 $resolved = 0;
267 foreach ( $ancestry as $entry ) {
268 $orig_id = (int) $entry['folder_id'];
269 $orig_par = (int) $entry['placement_parent_id'];
270 // Rewrite: if our snapshot's recorded parent was ALSO an
271 // ancestor we recreated, use the new id.
272 $resolved_parent = isset( $id_map[ $orig_par ] )
273 ? (int) $id_map[ $orig_par ]
274 : $orig_par;
275
276 $folder = desktop_mode_files_get_folder( $orig_id, true );
277 if ( $folder ) {
278 // Folder still exists. If trashed, restore it (cascade
279 // brings back its own children that were trashed via
280 // folder cascade).
281 if ( ! empty( $folder['trashed_at_ms'] ) ) {
282 desktop_mode_files_restore_folder( $user_id, $orig_id );
283 }
284 $id_map[ $orig_id ] = $orig_id;
285 $resolved = $orig_id;
286 continue;
287 }
288
289 // Folder is gone — recreate it and place it under the
290 // resolved parent. Owner falls back to the acting user
291 // when the original owner can't be inferred (shared-
292 // folder edge case Phase 6 will revisit).
293 $owner_id = (int) ( $entry['folder_owner_id'] ?: $user_id );
294 $new_id = desktop_mode_files_create_folder( $owner_id, array(
295 'name' => (string) $entry['folder_name'],
296 'share_mode' => (string) $entry['folder_share_mode'],
297 'share_meta' => $entry['folder_share_meta'],
298 ) );
299 if ( is_wp_error( $new_id ) ) {
300 // Fall back to root — restoring at the wrong place is
301 // strictly better than failing the restore outright.
302 $resolved = $resolved_parent;
303 $id_map[ $orig_id ] = $resolved;
304 continue;
305 }
306 // Place the recreated folder where the snapshot says.
307 desktop_mode_files_place(
308 $user_id,
309 $resolved_parent,
310 'folder',
311 (string) $new_id,
312 array(
313 'x' => (int) $entry['placement_x'],
314 'y' => (int) $entry['placement_y'],
315 )
316 );
317 $id_map[ $orig_id ] = (int) $new_id;
318 $resolved = (int) $new_id;
319 }
320 return $resolved;
321 }
322
323 /* ================================================================== *
324 * Placement: trash / restore / purge.
325 * ================================================================== */
326
327 /**
328 * Soft-trash a placement. Sets `trashed_at_ms`, `trashed_by`. Returns
329 * `true` on success, `WP_Error` on permission failure / missing row.
330 *
331 * Idempotent: trashing an already-trashed placement is a no-op
332 * success.
333 *
334 * @param int $user_id Acting user.
335 * @param int $placement_id Placement id.
336 * @return true|WP_Error
337 */
338 function desktop_mode_files_trash_placement( $user_id, $placement_id ) {
339 global $wpdb;
340 $user_id = (int) $user_id;
341 $placement_id = (int) $placement_id;
342 $tables = desktop_mode_files_table_names();
343
344 $row = $wpdb->get_row(
345 $wpdb->prepare(
346 "SELECT * FROM {$tables['placements']} WHERE id = %d",
347 $placement_id
348 ),
349 ARRAY_A
350 );
351 if ( ! $row ) {
352 return new WP_Error(
353 'desktop_mode_files_placement_not_found',
354 __( 'Placement not found.', 'desktop-mode' ),
355 array( 'status' => 404 )
356 );
357 }
358 if ( null !== $row['trashed_at_ms'] && '' !== $row['trashed_at_ms'] ) {
359 return true;
360 }
361 if ( ! desktop_mode_files_user_can_trash_placement( $user_id, $row ) ) {
362 return new WP_Error(
363 'desktop_mode_files_forbidden',
364 __( 'You do not have permission to trash this item.', 'desktop-mode' ),
365 array( 'status' => 403 )
366 );
367 }
368
369 /**
370 * Fires before a placement is trashed.
371 *
372 * @param int $placement_id Placement id.
373 * @param int $user_id Acting user.
374 * @param array $row Placement row.
375 */
376 do_action( 'desktop_mode_files_before_trash_placement', $placement_id, $user_id, $row );
377
378 $now = desktop_mode_files_now_ms();
379 $ancestry = desktop_mode_files_capture_ancestry( (int) $row['parent_id'] );
380 $meta = wp_json_encode( array( 'ancestry' => $ancestry ) );
381 $result = $wpdb->update(
382 $tables['placements'],
383 array(
384 'trashed_at_ms' => $now,
385 'trashed_by' => $user_id,
386 'trashed_meta' => $meta,
387 'updated_at_ms' => $now,
388 ),
389 array( 'id' => $placement_id ),
390 array( '%d', '%d', '%s', '%d' ),
391 array( '%d' )
392 );
393 // `$wpdb->update` returns `false` on schema mismatch (e.g. the
394 // migration didn't add the column the function writes to). The
395 // REST layer would otherwise translate the silent no-op into a
396 // 200 OK and the UI would show "moved to trash" with nothing
397 // actually trashed.
398 if ( false === $result ) {
399 return new WP_Error(
400 'desktop_mode_files_trash_failed',
401 isset( $wpdb->last_error ) && $wpdb->last_error
402 ? (string) $wpdb->last_error
403 : __( 'Failed to write trash row.', 'desktop-mode' ),
404 array( 'status' => 500 )
405 );
406 }
407
408 /**
409 * Fires after a placement is trashed.
410 *
411 * @param int $placement_id Placement id.
412 * @param int $user_id Acting user.
413 */
414 do_action( 'desktop_mode_files_after_trash_placement', $placement_id, $user_id );
415
416 return true;
417 }
418
419 /**
420 * Restore a trashed placement back to its original folder + (x, y).
421 *
422 * @param int $user_id Acting user.
423 * @param int $placement_id Placement id.
424 * @return true|WP_Error
425 */
426 function desktop_mode_files_restore_placement( $user_id, $placement_id ) {
427 global $wpdb;
428 $user_id = (int) $user_id;
429 $placement_id = (int) $placement_id;
430 $tables = desktop_mode_files_table_names();
431
432 $row = $wpdb->get_row(
433 $wpdb->prepare(
434 "SELECT * FROM {$tables['placements']} WHERE id = %d",
435 $placement_id
436 ),
437 ARRAY_A
438 );
439 if ( ! $row ) {
440 return new WP_Error(
441 'desktop_mode_files_placement_not_found',
442 __( 'Placement not found.', 'desktop-mode' ),
443 array( 'status' => 404 )
444 );
445 }
446 if ( null === $row['trashed_at_ms'] || '' === $row['trashed_at_ms'] ) {
447 return true; // Already active — idempotent.
448 }
449 if ( ! desktop_mode_files_user_can_restore_placement( $user_id, $row ) ) {
450 return new WP_Error(
451 'desktop_mode_files_forbidden',
452 __( 'You do not have permission to restore this item.', 'desktop-mode' ),
453 array( 'status' => 403 )
454 );
455 }
456
457 // Resolve the parent folder. Three branches:
458 // - parent is alive → reuse the same id
459 // - parent is trashed → cascade-restore it (and rest of the
460 // chain) before placing the leaf
461 // - parent is gone → walk the captured ancestry and
462 // recreate every missing folder in
463 // the chain
464 $original_parent_id = (int) $row['parent_id'];
465 $resolved_parent_id = $original_parent_id;
466 if ( $original_parent_id > 0 ) {
467 $parent_alive = desktop_mode_files_get_folder( $original_parent_id, true );
468 if ( $parent_alive ) {
469 if ( ! empty( $parent_alive['trashed_at_ms'] ) ) {
470 // Cascade restore — reach into the snapshot the
471 // folder itself stored at trash time so any chain
472 // above it is also resurrected.
473 $folder_restore = desktop_mode_files_restore_folder( $user_id, $original_parent_id );
474 if ( is_wp_error( $folder_restore ) ) {
475 return $folder_restore;
476 }
477 }
478 $resolved_parent_id = $original_parent_id;
479 } else {
480 // Hard-deleted parent — read the ancestry snapshot we
481 // stored at trash time and resurrect the chain.
482 $meta_raw = isset( $row['trashed_meta'] ) ? (string) $row['trashed_meta'] : '';
483 $decoded = '' !== $meta_raw ? json_decode( $meta_raw, true ) : null;
484 $ancestry = ( is_array( $decoded ) && isset( $decoded['ancestry'] ) && is_array( $decoded['ancestry'] ) )
485 ? $decoded['ancestry']
486 : array();
487 $resolved_parent_id = desktop_mode_files_resurrect_ancestry( $user_id, $ancestry );
488 }
489 }
490
491 /**
492 * Fires before a placement is restored.
493 *
494 * @param int $placement_id
495 * @param int $user_id
496 * @param array $row
497 */
498 do_action( 'desktop_mode_files_before_restore_placement', $placement_id, $user_id, $row );
499
500 $wpdb->update(
501 $tables['placements'],
502 array(
503 'parent_id' => $resolved_parent_id,
504 'trashed_at_ms' => null,
505 'trashed_by' => null,
506 'trashed_via_folder' => null,
507 'trashed_meta' => null,
508 'updated_at_ms' => desktop_mode_files_now_ms(),
509 ),
510 array( 'id' => $placement_id ),
511 array( '%d', null, null, null, null, '%d' ),
512 array( '%d' )
513 );
514
515 // Enforce the "tombstones never refer to alive rows" invariant:
516 // a placement coming back to life must not carry lingering
517 // tombstones from an earlier (reversible) removal. Without this,
518 // every heartbeat tick would re-deliver those tombstones to the
519 // client and the row would flicker off the desktop on each tick.
520 desktop_mode_files_clear_tombstones_for( 'placement', $placement_id );
521
522 /**
523 * @param int $placement_id
524 * @param int $user_id
525 */
526 do_action( 'desktop_mode_files_after_restore_placement', $placement_id, $user_id );
527
528 return true;
529 }
530
531 /**
532 * Permanently delete a trashed placement.
533 *
534 * @param int $user_id
535 * @param int $placement_id
536 * @return true|WP_Error
537 */
538 function desktop_mode_files_purge_placement( $user_id, $placement_id ) {
539 global $wpdb;
540 $user_id = (int) $user_id;
541 $placement_id = (int) $placement_id;
542 $tables = desktop_mode_files_table_names();
543
544 $row = $wpdb->get_row(
545 $wpdb->prepare(
546 "SELECT * FROM {$tables['placements']} WHERE id = %d",
547 $placement_id
548 ),
549 ARRAY_A
550 );
551 if ( ! $row ) {
552 return true; // Already gone — idempotent.
553 }
554 if ( ! desktop_mode_files_user_can_purge_placement( $user_id, $row ) ) {
555 return new WP_Error(
556 'desktop_mode_files_forbidden',
557 __( 'You do not have permission to delete this item.', 'desktop-mode' ),
558 array( 'status' => 403 )
559 );
560 }
561
562 /**
563 * @param int $placement_id
564 * @param int $user_id
565 * @param array $row
566 */
567 do_action( 'desktop_mode_files_before_purge_placement', $placement_id, $user_id, $row );
568
569 $wpdb->delete( $tables['placements'], array( 'id' => $placement_id ), array( '%d' ) );
570
571 // Mirror `desktop_mode_files_remove()`: a purge IS a permanent
572 // removal, so the same lifecycle action fires. Load-bearing for
573 // the `upload` type — the stored-files listener deletes the real
574 // bytes when the owner's last placement goes away; without this
575 // the recycle-bin "Delete forever" path leaked them.
576 do_action(
577 'desktop_mode_file_unplaced',
578 $placement_id,
579 desktop_mode_files_normalize_placement_row( $row )
580 );
581
582 /**
583 * @param int $placement_id
584 * @param int $user_id
585 */
586 do_action( 'desktop_mode_files_after_purge_placement', $placement_id, $user_id );
587
588 return true;
589 }
590
591 /* ================================================================== *
592 * Folder: trash / restore / purge (cascades to child placements).
593 * ================================================================== */
594
595 /**
596 * Soft-trash a folder. Cascades to every child placement (any
597 * placement whose `parent_id = folder_id`), marking them with
598 * `trashed_via_folder = folder_id` so a later restore brings back
599 * the same set without time-window heuristics.
600 *
601 * Idempotent on already-trashed.
602 *
603 * @param int $user_id
604 * @param int $folder_id
605 * @return true|WP_Error
606 */
607 function desktop_mode_files_trash_folder( $user_id, $folder_id ) {
608 global $wpdb;
609 $user_id = (int) $user_id;
610 $folder_id = (int) $folder_id;
611 $tables = desktop_mode_files_table_names();
612
613 $row = $wpdb->get_row(
614 $wpdb->prepare(
615 "SELECT * FROM {$tables['folders']} WHERE id = %d",
616 $folder_id
617 ),
618 ARRAY_A
619 );
620 if ( ! $row ) {
621 return new WP_Error(
622 'desktop_mode_files_folder_not_found',
623 __( 'Folder not found.', 'desktop-mode' ),
624 array( 'status' => 404 )
625 );
626 }
627 if ( null !== $row['trashed_at_ms'] && '' !== $row['trashed_at_ms'] ) {
628 return true;
629 }
630 if ( ! desktop_mode_files_user_can_trash_folder( $user_id, $row ) ) {
631 return new WP_Error(
632 'desktop_mode_files_forbidden',
633 __( 'You do not have permission to trash this folder.', 'desktop-mode' ),
634 array( 'status' => 403 )
635 );
636 }
637
638 /**
639 * @param int $folder_id
640 * @param int $user_id
641 * @param array $row
642 */
643 do_action( 'desktop_mode_files_before_trash_folder', $folder_id, $user_id, $row );
644
645 $now = desktop_mode_files_now_ms();
646 // Capture the folder's own placement-chain ancestry so a future
647 // restore can resurrect any parent folders that got hard-deleted
648 // while this one was sitting in trash.
649 $folder_placement = $wpdb->get_row(
650 $wpdb->prepare(
651 "SELECT parent_id FROM {$tables['placements']}
652 WHERE file_type = 'folder' AND file_ref = %s
653 ORDER BY id ASC LIMIT 1",
654 (string) $folder_id
655 ),
656 ARRAY_A
657 );
658 $folder_ancestry = $folder_placement
659 ? desktop_mode_files_capture_ancestry( (int) $folder_placement['parent_id'] )
660 : array();
661 $folder_meta = wp_json_encode( array( 'ancestry' => $folder_ancestry ) );
662
663 // Trash the folder row.
664 $folder_update = $wpdb->update(
665 $tables['folders'],
666 array(
667 'trashed_at_ms' => $now,
668 'trashed_by' => $user_id,
669 'trashed_meta' => $folder_meta,
670 'updated_at_ms' => $now,
671 ),
672 array( 'id' => $folder_id ),
673 array( '%d', '%d', '%s', '%d' ),
674 array( '%d' )
675 );
676 if ( false === $folder_update ) {
677 return new WP_Error(
678 'desktop_mode_files_trash_failed',
679 isset( $wpdb->last_error ) && $wpdb->last_error
680 ? (string) $wpdb->last_error
681 : __( 'Failed to trash folder.', 'desktop-mode' ),
682 array( 'status' => 500 )
683 );
684 }
685 // Cascade to child placements that are still active. Mark
686 // `trashed_via_folder` so the restore knows which children to
687 // resurrect. Already-trashed children keep their state.
688 //
689 // Each child also gets its own ancestry snapshot so restoring
690 // just one child later (after the parent folder was hard-
691 // deleted) can still recreate the chain — same shape as a
692 // direct trash. Captured per-row because every child shares
693 // the same parent chain, so we compute once.
694 $ancestry = desktop_mode_files_capture_ancestry( $folder_id );
695 $meta = wp_json_encode( array( 'ancestry' => $ancestry ) );
696 $wpdb->query(
697 $wpdb->prepare(
698 "UPDATE {$tables['placements']}
699 SET trashed_at_ms = %d,
700 trashed_by = %d,
701 trashed_via_folder = %d,
702 trashed_meta = %s,
703 updated_at_ms = %d
704 WHERE parent_id = %d
705 AND trashed_at_ms IS NULL",
706 $now,
707 $user_id,
708 $folder_id,
709 $meta,
710 $now,
711 $folder_id
712 )
713 );
714 // Cascade trash to nested folders too. Recurses one level via
715 // IDs; deep folder trees iterate.
716 $child_folder_ids = $wpdb->get_col(
717 $wpdb->prepare(
718 "SELECT f.id FROM {$tables['folders']} f
719 INNER JOIN {$tables['placements']} p ON p.file_type = 'folder' AND p.file_ref = CAST( f.id AS CHAR )
720 WHERE p.parent_id = %d AND f.trashed_at_ms IS NULL",
721 $folder_id
722 )
723 );
724 foreach ( (array) $child_folder_ids as $child_id ) {
725 desktop_mode_files_trash_folder( $user_id, (int) $child_id );
726 }
727
728 /**
729 * @param int $folder_id
730 * @param int $user_id
731 */
732 do_action( 'desktop_mode_files_after_trash_folder', $folder_id, $user_id );
733
734 return true;
735 }
736
737 /**
738 * Restore a trashed folder + every placement that was trashed via
739 * its cascade. Items that were trashed BEFORE the folder cascade
740 * (i.e. `trashed_via_folder IS NULL`) stay in the recycle bin —
741 * the user trashed them deliberately, separate from the folder.
742 *
743 * @param int $user_id
744 * @param int $folder_id
745 * @return true|WP_Error
746 */
747 function desktop_mode_files_restore_folder( $user_id, $folder_id ) {
748 global $wpdb;
749 $user_id = (int) $user_id;
750 $folder_id = (int) $folder_id;
751 $tables = desktop_mode_files_table_names();
752
753 $row = $wpdb->get_row(
754 $wpdb->prepare(
755 "SELECT * FROM {$tables['folders']} WHERE id = %d",
756 $folder_id
757 ),
758 ARRAY_A
759 );
760 if ( ! $row ) {
761 return new WP_Error(
762 'desktop_mode_files_folder_not_found',
763 __( 'Folder not found.', 'desktop-mode' ),
764 array( 'status' => 404 )
765 );
766 }
767 if ( null === $row['trashed_at_ms'] || '' === $row['trashed_at_ms'] ) {
768 return true;
769 }
770 if ( ! desktop_mode_files_user_can_restore_folder( $user_id, $row ) ) {
771 return new WP_Error(
772 'desktop_mode_files_forbidden',
773 __( 'You do not have permission to restore this folder.', 'desktop-mode' ),
774 array( 'status' => 403 )
775 );
776 }
777
778 /**
779 * @param int $folder_id
780 * @param int $user_id
781 * @param array $row
782 */
783 do_action( 'desktop_mode_files_before_restore_folder', $folder_id, $user_id, $row );
784
785 $now = desktop_mode_files_now_ms();
786 // Snapshot nested folder ids BEFORE we null `trashed_via_folder`
787 // on the placements — that column is the only stable link from
788 // a child folder's placement back to the parent cascade.
789 $nested_ids = $wpdb->get_col(
790 $wpdb->prepare(
791 "SELECT DISTINCT CAST( p.file_ref AS UNSIGNED ) AS fid
792 FROM {$tables['placements']} p
793 WHERE p.file_type = 'folder'
794 AND p.parent_id = %d
795 AND p.trashed_via_folder = %d",
796 $folder_id,
797 $folder_id
798 )
799 );
800
801 $wpdb->update(
802 $tables['folders'],
803 array(
804 'trashed_at_ms' => null,
805 'trashed_by' => null,
806 'trashed_meta' => null,
807 'updated_at_ms' => $now,
808 ),
809 array( 'id' => $folder_id ),
810 array( null, null, null, '%d' ),
811 array( '%d' )
812 );
813 // If the folder's own placement points at a parent_id that's
814 // been hard-deleted in the meantime, resurrect the chain from
815 // the snapshot taken at trash time.
816 $meta_raw = isset( $row['trashed_meta'] ) ? (string) $row['trashed_meta'] : '';
817 $decoded = '' !== $meta_raw ? json_decode( $meta_raw, true ) : null;
818 $ancestry = ( is_array( $decoded ) && isset( $decoded['ancestry'] ) && is_array( $decoded['ancestry'] ) )
819 ? $decoded['ancestry']
820 : array();
821 if ( ! empty( $ancestry ) ) {
822 $folder_placement_row = $wpdb->get_row(
823 $wpdb->prepare(
824 "SELECT id, parent_id FROM {$tables['placements']}
825 WHERE file_type = 'folder' AND file_ref = %s
826 ORDER BY id ASC LIMIT 1",
827 (string) $folder_id
828 ),
829 ARRAY_A
830 );
831 if ( $folder_placement_row ) {
832 $origin_parent = (int) $folder_placement_row['parent_id'];
833 $alive = $origin_parent > 0
834 ? desktop_mode_files_get_folder( $origin_parent, true )
835 : null;
836 if ( $origin_parent > 0 && ! $alive ) {
837 $resolved = desktop_mode_files_resurrect_ancestry( $user_id, $ancestry );
838 $wpdb->update(
839 $tables['placements'],
840 array(
841 'parent_id' => $resolved,
842 'updated_at_ms' => $now,
843 ),
844 array( 'id' => (int) $folder_placement_row['id'] ),
845 array( '%d', '%d' ),
846 array( '%d' )
847 );
848 }
849 }
850 }
851 // Restore placements that this folder's trash had cascaded.
852 $wpdb->query(
853 $wpdb->prepare(
854 "UPDATE {$tables['placements']}
855 SET trashed_at_ms = NULL,
856 trashed_by = NULL,
857 trashed_via_folder = NULL,
858 trashed_meta = NULL,
859 updated_at_ms = %d
860 WHERE trashed_via_folder = %d",
861 $now,
862 $folder_id
863 )
864 );
865 // Recursively restore nested folders captured in the snapshot.
866 foreach ( (array) $nested_ids as $nid ) {
867 desktop_mode_files_restore_folder( $user_id, (int) $nid );
868 }
869
870 // Enforce the "tombstones never refer to alive rows" invariant
871 // across the restored cohort: the folder itself, every cascade-
872 // restored placement that lived inside it, and every nested
873 // folder recursed into above already clears its own. Here we
874 // scrub the FOLDER's own tombstones plus those of every cascade-
875 // restored placement so a fresh heartbeat tick can't surface
876 // them as `removed.*` against the now-alive rows.
877 desktop_mode_files_clear_tombstones_for( 'folder', $folder_id );
878 $restored_placement_ids = $wpdb->get_col(
879 $wpdb->prepare(
880 "SELECT id FROM {$tables['placements']}
881 WHERE trashed_via_folder IS NULL
882 AND ( parent_id = %d OR ( file_type = 'folder' AND file_ref = %s ) )",
883 $folder_id,
884 (string) $folder_id
885 )
886 );
887 foreach ( (array) $restored_placement_ids as $rpid ) {
888 desktop_mode_files_clear_tombstones_for( 'placement', (int) $rpid );
889 }
890
891 /**
892 * @param int $folder_id
893 * @param int $user_id
894 */
895 do_action( 'desktop_mode_files_after_restore_folder', $folder_id, $user_id );
896
897 return true;
898 }
899
900 /**
901 * Permanently delete a trashed folder and all its trashed-via-
902 * cascade child placements. Independent placements that landed in
903 * the trash separately stay there.
904 *
905 * @param int $user_id
906 * @param int $folder_id
907 * @return true|WP_Error
908 */
909 function desktop_mode_files_purge_folder( $user_id, $folder_id ) {
910 global $wpdb;
911 $user_id = (int) $user_id;
912 $folder_id = (int) $folder_id;
913 $tables = desktop_mode_files_table_names();
914
915 $row = $wpdb->get_row(
916 $wpdb->prepare(
917 "SELECT * FROM {$tables['folders']} WHERE id = %d",
918 $folder_id
919 ),
920 ARRAY_A
921 );
922 if ( ! $row ) {
923 return true;
924 }
925 if ( ! desktop_mode_files_user_can_purge_folder( $user_id, $row ) ) {
926 return new WP_Error(
927 'desktop_mode_files_forbidden',
928 __( 'You do not have permission to delete this folder.', 'desktop-mode' ),
929 array( 'status' => 403 )
930 );
931 }
932
933 /**
934 * @param int $folder_id
935 * @param int $user_id
936 * @param array $row
937 */
938 do_action( 'desktop_mode_files_before_purge_folder', $folder_id, $user_id, $row );
939
940 // Cascade-revoke every share + per-user decision for the folder
941 // BEFORE deleting the folder row. Without this, purge left
942 // orphan `folder_shares` + `share_user_decisions` rows pointing
943 // at a folder id that no longer exists — `compute_visible_folders`
944 // would still join them, and the row leak grew with every
945 // recycle-bin empty. Mirrors the same cleanup
946 // `desktop_mode_files_delete_folder_recursive` does for the
947 // "delete from desktop" path.
948 // `target_type` scoping is load-bearing: `folder_id` carries a
949 // STORED-FILE id on `target_type='file'` rows, and the two id
950 // sequences are independent — without the predicate a folder
951 // purge wipes an unrelated user's file share that happens to
952 // collide numerically.
953 $share_ids = (array) $wpdb->get_col(
954 $wpdb->prepare(
955 "SELECT id FROM {$tables['shares']} WHERE target_type = 'folder' AND folder_id = %d",
956 $folder_id
957 )
958 );
959 if ( ! empty( $share_ids ) ) {
960 $placeholders = implode( ',', array_fill( 0, count( $share_ids ), '%d' ) );
961 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
962 $wpdb->query(
963 $wpdb->prepare(
964 "DELETE FROM {$tables['decisions']} WHERE share_id IN ($placeholders)",
965 $share_ids
966 )
967 );
968 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
969 $wpdb->query(
970 $wpdb->prepare(
971 "DELETE FROM {$tables['shares']} WHERE id IN ($placeholders)",
972 $share_ids
973 )
974 );
975 }
976
977 // Drop every placement that points AT this folder (recipients'
978 // root tiles + the owner's own), with tombstones so connected
979 // clients scrub the tile via the heartbeat.
980 $pointing_ids = (array) $wpdb->get_col(
981 $wpdb->prepare(
982 "SELECT id FROM {$tables['placements']}
983 WHERE file_type = 'folder' AND file_ref = %s",
984 (string) $folder_id
985 )
986 );
987 foreach ( $pointing_ids as $pid ) {
988 desktop_mode_files_write_tombstone( 'placement', (int) $pid );
989 }
990 if ( ! empty( $pointing_ids ) ) {
991 $placeholders = implode( ',', array_fill( 0, count( $pointing_ids ), '%d' ) );
992 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
993 $wpdb->query(
994 $wpdb->prepare(
995 "DELETE FROM {$tables['placements']} WHERE id IN ($placeholders)",
996 $pointing_ids
997 )
998 );
999 }
1000
1001 // Upload placements among the cascade-trashed children carry
1002 // real bytes — run the stored-files deletion contract for them
1003 // after the rows go. Direct guarded call (not the public
1004 // `desktop_mode_file_unplaced` action) so cascade hook semantics
1005 // for every other type stay unchanged.
1006 $cascade_upload_rows = (array) $wpdb->get_results(
1007 $wpdb->prepare(
1008 "SELECT * FROM {$tables['placements']}
1009 WHERE trashed_via_folder = %d AND file_type = 'upload'",
1010 $folder_id
1011 ),
1012 ARRAY_A
1013 );
1014 $wpdb->delete(
1015 $tables['placements'],
1016 array( 'trashed_via_folder' => $folder_id ),
1017 array( '%d' )
1018 );
1019 if ( function_exists( 'desktop_mode_stored_files_handle_unplaced' ) ) {
1020 foreach ( $cascade_upload_rows as $upload_row ) {
1021 desktop_mode_stored_files_handle_unplaced(
1022 (int) $upload_row['id'],
1023 desktop_mode_files_normalize_placement_row( $upload_row )
1024 );
1025 }
1026 }
1027 $wpdb->delete( $tables['folders'], array( 'id' => $folder_id ), array( '%d' ) );
1028
1029 /**
1030 * @param int $folder_id
1031 * @param int $user_id
1032 */
1033 do_action( 'desktop_mode_files_after_purge_folder', $folder_id, $user_id );
1034
1035 return true;
1036 }
1037
1038 /* ================================================================== *
1039 * Recycle-bin list builder.
1040 * ================================================================== */
1041
1042 /**
1043 * Count of trashed placements + folders surfaced to the recycle bin
1044 * for `$user_id`. Mirrors `_list_trashed_for_recycle_bin`'s "skip
1045 * cascaded children" rule so the badge matches the visible list.
1046 *
1047 * @param int $user_id Owner.
1048 * @return int
1049 */
1050 function desktop_mode_files_count_trashed_for_recycle_bin( $user_id ) {
1051 global $wpdb;
1052 $user_id = (int) $user_id;
1053 if ( $user_id <= 0 ) {
1054 return 0;
1055 }
1056 $tables = desktop_mode_files_table_names();
1057
1058 $placements = (int) $wpdb->get_var(
1059 $wpdb->prepare(
1060 "SELECT COUNT(*) FROM {$tables['placements']}
1061 WHERE owner_id = %d
1062 AND trashed_at_ms IS NOT NULL
1063 AND trashed_via_folder IS NULL",
1064 $user_id
1065 )
1066 );
1067 $folders = (int) $wpdb->get_var(
1068 $wpdb->prepare(
1069 "SELECT COUNT(*) FROM {$tables['folders']}
1070 WHERE owner_id = %d AND trashed_at_ms IS NOT NULL",
1071 $user_id
1072 )
1073 );
1074 return $placements + $folders;
1075 }
1076
1077 /**
1078 * Return the trashed placements + folders for a user, shaped as
1079 * recycle-bin items. Used by the recycle bin's REST list endpoint
1080 * to merge files-on-the-desktop trash with the WP-core trash.
1081 *
1082 * @param int $user_id Owner.
1083 * @return array[] List of recycle-bin item shapes.
1084 */
1085 function desktop_mode_files_list_trashed_for_recycle_bin( $user_id ) {
1086 global $wpdb;
1087 $user_id = (int) $user_id;
1088 $tables = desktop_mode_files_table_names();
1089 $out = array();
1090
1091 // Trashed placements owned by this user.
1092 $placements = $wpdb->get_results(
1093 $wpdb->prepare(
1094 "SELECT * FROM {$tables['placements']}
1095 WHERE owner_id = %d AND trashed_at_ms IS NOT NULL
1096 ORDER BY trashed_at_ms DESC",
1097 $user_id
1098 ),
1099 ARRAY_A
1100 );
1101 foreach ( (array) $placements as $row ) {
1102 // Skip cascaded children — the parent folder represents
1103 // the whole bundle in the recycle bin.
1104 if ( ! empty( $row['trashed_via_folder'] ) ) {
1105 continue;
1106 }
1107 $file = function_exists( 'desktop_mode_resolve_file' )
1108 ? desktop_mode_resolve_file( $row['file_type'], $row['file_ref'] )
1109 : null;
1110 $title = $file ? (string) $file->title() : (string) $row['file_type'];
1111 $icon = $file ? (string) $file->icon() : 'dashicons-no-alt';
1112 // Two recycle-bin buckets:
1113 // - `shortcut` → plugin-registered icons (file_type='shortcut')
1114 // - `placement` → every other placement (post / page /
1115 // attachment / user / term / comment / …)
1116 // Lets the bin's type-filter tabs split "Shortcuts" from
1117 // "Files" without overloading either label.
1118 $bucket = ( 'shortcut' === (string) $row['file_type'] )
1119 ? 'shortcut'
1120 : 'placement';
1121 $subtitle = ( 'shortcut' === $bucket )
1122 ? __( 'Desktop shortcut', 'desktop-mode' )
1123 : sprintf(
1124 /* translators: %s: file-type slug like 'post', 'attachment'. */
1125 __( '%s on desktop', 'desktop-mode' ),
1126 (string) $row['file_type']
1127 );
1128 // `type_label` is the short uppercase badge the JS renders
1129 // inline before the title. Most placements collapse to the
1130 // generic "Placement" badge (the JS humanizes the bucket
1131 // slug when no label is set). `link` placements — created
1132 // via "New URL" on the desktop — deserve a more specific
1133 // label so they read as URL-shortcuts, not generic tiles.
1134 $item = array(
1135 'id' => (int) $row['id'],
1136 'type' => $bucket,
1137 'title' => $title,
1138 'subtitle' => $subtitle,
1139 'mime' => '',
1140 'preview' => $file ? (string) $file->preview_url() : '',
1141 'icon' => $icon,
1142 'deleted_at' => gmdate( 'c', (int) round( (int) $row['trashed_at_ms'] / 1000 ) ),
1143 'deleted_by' => '',
1144 'deleted_by_id' => (int) $row['trashed_by'],
1145 'can_restore' => desktop_mode_files_user_can_restore_placement( $user_id, $row ),
1146 'can_purge' => desktop_mode_files_user_can_purge_placement( $user_id, $row ),
1147 'edit_link' => '',
1148 );
1149 if ( 'link' === (string) $row['file_type'] ) {
1150 $item['type_label'] = __( 'URL', 'desktop-mode' );
1151 }
1152 $out[] = $item;
1153 }
1154
1155 // Trashed folders owned by this user.
1156 $folders = $wpdb->get_results(
1157 $wpdb->prepare(
1158 "SELECT * FROM {$tables['folders']}
1159 WHERE owner_id = %d AND trashed_at_ms IS NOT NULL
1160 ORDER BY trashed_at_ms DESC",
1161 $user_id
1162 ),
1163 ARRAY_A
1164 );
1165 foreach ( (array) $folders as $row ) {
1166 $child_count = (int) $wpdb->get_var(
1167 $wpdb->prepare(
1168 "SELECT COUNT(*) FROM {$tables['placements']}
1169 WHERE trashed_via_folder = %d",
1170 (int) $row['id']
1171 )
1172 );
1173 $out[] = array(
1174 'id' => (int) $row['id'],
1175 'type' => 'folder',
1176 'title' => (string) $row['name'],
1177 'subtitle' => $child_count > 0
1178 ? sprintf(
1179 /* translators: %d: number of items inside the trashed folder. */
1180 _n( 'Folder · %d item inside', 'Folder · %d items inside', $child_count, 'desktop-mode' ),
1181 $child_count
1182 )
1183 : __( 'Folder · empty', 'desktop-mode' ),
1184 'mime' => '',
1185 'preview' => '',
1186 'icon' => 'dashicons-portfolio',
1187 'deleted_at' => gmdate( 'c', (int) round( (int) $row['trashed_at_ms'] / 1000 ) ),
1188 'deleted_by' => '',
1189 'deleted_by_id' => (int) $row['trashed_by'],
1190 'can_restore' => desktop_mode_files_user_can_restore_folder( $user_id, $row ),
1191 'can_purge' => desktop_mode_files_user_can_purge_folder( $user_id, $row ),
1192 'edit_link' => '',
1193 );
1194 }
1195
1196 // Resolve display-name for the deleted-by id once per user.
1197 $user_cache = array();
1198 foreach ( $out as &$item ) {
1199 $uid = (int) $item['deleted_by_id'];
1200 if ( $uid <= 0 ) {
1201 continue;
1202 }
1203 if ( ! isset( $user_cache[ $uid ] ) ) {
1204 $u = get_userdata( $uid );
1205 $user_cache[ $uid ] = $u ? $u->display_name : '';
1206 }
1207 $item['deleted_by'] = $user_cache[ $uid ];
1208 }
1209 unset( $item );
1210
1211 return $out;
1212 }
1213