PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.6
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.6
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / desktop-files / trash.php

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

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