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

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