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

1,246 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 * 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 /**
618 * @since 0.8.0
619 *
620 * @param int $placement_id
621 * @param int $user_id
622 */
623 do_action( 'desktop_mode_files_after_purge_placement', $placement_id, $user_id );
624
625 return true;
626 }
627
628 /* ================================================================== *
629 * Folder: trash / restore / purge (cascades to child placements).
630 * ================================================================== */
631
632 /**
633 * Soft-trash a folder. Cascades to every child placement (any
634 * placement whose `parent_id = folder_id`), marking them with
635 * `trashed_via_folder = folder_id` so a later restore brings back
636 * the same set without time-window heuristics.
637 *
638 * Idempotent on already-trashed.
639 *
640 * @since 0.8.0
641 *
642 * @param int $user_id
643 * @param int $folder_id
644 * @return true|WP_Error
645 */
646 function desktop_mode_files_trash_folder( $user_id, $folder_id ) {
647 global $wpdb;
648 $user_id = (int) $user_id;
649 $folder_id = (int) $folder_id;
650 $tables = desktop_mode_files_table_names();
651
652 $row = $wpdb->get_row(
653 $wpdb->prepare(
654 "SELECT * FROM {$tables['folders']} WHERE id = %d",
655 $folder_id
656 ),
657 ARRAY_A
658 );
659 if ( ! $row ) {
660 return new WP_Error(
661 'desktop_mode_files_folder_not_found',
662 __( 'Folder not found.', 'desktop-mode' ),
663 array( 'status' => 404 )
664 );
665 }
666 if ( null !== $row['trashed_at_ms'] && '' !== $row['trashed_at_ms'] ) {
667 return true;
668 }
669 if ( ! desktop_mode_files_user_can_trash_folder( $user_id, $row ) ) {
670 return new WP_Error(
671 'desktop_mode_files_forbidden',
672 __( 'You do not have permission to trash this folder.', 'desktop-mode' ),
673 array( 'status' => 403 )
674 );
675 }
676
677 /**
678 * @since 0.8.0
679 *
680 * @param int $folder_id
681 * @param int $user_id
682 * @param array $row
683 */
684 do_action( 'desktop_mode_files_before_trash_folder', $folder_id, $user_id, $row );
685
686 $now = desktop_mode_files_now_ms();
687 // Capture the folder's own placement-chain ancestry so a future
688 // restore can resurrect any parent folders that got hard-deleted
689 // while this one was sitting in trash.
690 $folder_placement = $wpdb->get_row(
691 $wpdb->prepare(
692 "SELECT parent_id FROM {$tables['placements']}
693 WHERE file_type = 'folder' AND file_ref = %s
694 ORDER BY id ASC LIMIT 1",
695 (string) $folder_id
696 ),
697 ARRAY_A
698 );
699 $folder_ancestry = $folder_placement
700 ? desktop_mode_files_capture_ancestry( (int) $folder_placement['parent_id'] )
701 : array();
702 $folder_meta = wp_json_encode( array( 'ancestry' => $folder_ancestry ) );
703
704 // Trash the folder row.
705 $folder_update = $wpdb->update(
706 $tables['folders'],
707 array(
708 'trashed_at_ms' => $now,
709 'trashed_by' => $user_id,
710 'trashed_meta' => $folder_meta,
711 'updated_at_ms' => $now,
712 ),
713 array( 'id' => $folder_id ),
714 array( '%d', '%d', '%s', '%d' ),
715 array( '%d' )
716 );
717 if ( false === $folder_update ) {
718 return new WP_Error(
719 'desktop_mode_files_trash_failed',
720 isset( $wpdb->last_error ) && $wpdb->last_error
721 ? (string) $wpdb->last_error
722 : __( 'Failed to trash folder.', 'desktop-mode' ),
723 array( 'status' => 500 )
724 );
725 }
726 // Cascade to child placements that are still active. Mark
727 // `trashed_via_folder` so the restore knows which children to
728 // resurrect. Already-trashed children keep their state.
729 //
730 // Each child also gets its own ancestry snapshot so restoring
731 // just one child later (after the parent folder was hard-
732 // deleted) can still recreate the chain — same shape as a
733 // direct trash. Captured per-row because every child shares
734 // the same parent chain, so we compute once.
735 $ancestry = desktop_mode_files_capture_ancestry( $folder_id );
736 $meta = wp_json_encode( array( 'ancestry' => $ancestry ) );
737 $wpdb->query(
738 $wpdb->prepare(
739 "UPDATE {$tables['placements']}
740 SET trashed_at_ms = %d,
741 trashed_by = %d,
742 trashed_via_folder = %d,
743 trashed_meta = %s,
744 updated_at_ms = %d
745 WHERE parent_id = %d
746 AND trashed_at_ms IS NULL",
747 $now,
748 $user_id,
749 $folder_id,
750 $meta,
751 $now,
752 $folder_id
753 )
754 );
755 // Cascade trash to nested folders too. Recurses one level via
756 // IDs; deep folder trees iterate.
757 $child_folder_ids = $wpdb->get_col(
758 $wpdb->prepare(
759 "SELECT f.id FROM {$tables['folders']} f
760 INNER JOIN {$tables['placements']} p ON p.file_type = 'folder' AND p.file_ref = CAST( f.id AS CHAR )
761 WHERE p.parent_id = %d AND f.trashed_at_ms IS NULL",
762 $folder_id
763 )
764 );
765 foreach ( (array) $child_folder_ids as $child_id ) {
766 desktop_mode_files_trash_folder( $user_id, (int) $child_id );
767 }
768
769 /**
770 * @since 0.8.0
771 *
772 * @param int $folder_id
773 * @param int $user_id
774 */
775 do_action( 'desktop_mode_files_after_trash_folder', $folder_id, $user_id );
776
777 return true;
778 }
779
780 /**
781 * Restore a trashed folder + every placement that was trashed via
782 * its cascade. Items that were trashed BEFORE the folder cascade
783 * (i.e. `trashed_via_folder IS NULL`) stay in the recycle bin —
784 * the user trashed them deliberately, separate from the folder.
785 *
786 * @since 0.8.0
787 *
788 * @param int $user_id
789 * @param int $folder_id
790 * @return true|WP_Error
791 */
792 function desktop_mode_files_restore_folder( $user_id, $folder_id ) {
793 global $wpdb;
794 $user_id = (int) $user_id;
795 $folder_id = (int) $folder_id;
796 $tables = desktop_mode_files_table_names();
797
798 $row = $wpdb->get_row(
799 $wpdb->prepare(
800 "SELECT * FROM {$tables['folders']} WHERE id = %d",
801 $folder_id
802 ),
803 ARRAY_A
804 );
805 if ( ! $row ) {
806 return new WP_Error(
807 'desktop_mode_files_folder_not_found',
808 __( 'Folder not found.', 'desktop-mode' ),
809 array( 'status' => 404 )
810 );
811 }
812 if ( null === $row['trashed_at_ms'] || '' === $row['trashed_at_ms'] ) {
813 return true;
814 }
815 if ( ! desktop_mode_files_user_can_restore_folder( $user_id, $row ) ) {
816 return new WP_Error(
817 'desktop_mode_files_forbidden',
818 __( 'You do not have permission to restore this folder.', 'desktop-mode' ),
819 array( 'status' => 403 )
820 );
821 }
822
823 /**
824 * @since 0.8.0
825 *
826 * @param int $folder_id
827 * @param int $user_id
828 * @param array $row
829 */
830 do_action( 'desktop_mode_files_before_restore_folder', $folder_id, $user_id, $row );
831
832 $now = desktop_mode_files_now_ms();
833 // Snapshot nested folder ids BEFORE we null `trashed_via_folder`
834 // on the placements — that column is the only stable link from
835 // a child folder's placement back to the parent cascade.
836 $nested_ids = $wpdb->get_col(
837 $wpdb->prepare(
838 "SELECT DISTINCT CAST( p.file_ref AS UNSIGNED ) AS fid
839 FROM {$tables['placements']} p
840 WHERE p.file_type = 'folder'
841 AND p.parent_id = %d
842 AND p.trashed_via_folder = %d",
843 $folder_id,
844 $folder_id
845 )
846 );
847
848 $wpdb->update(
849 $tables['folders'],
850 array(
851 'trashed_at_ms' => null,
852 'trashed_by' => null,
853 'trashed_meta' => null,
854 'updated_at_ms' => $now,
855 ),
856 array( 'id' => $folder_id ),
857 array( null, null, null, '%d' ),
858 array( '%d' )
859 );
860 // If the folder's own placement points at a parent_id that's
861 // been hard-deleted in the meantime, resurrect the chain from
862 // the snapshot taken at trash time.
863 $meta_raw = isset( $row['trashed_meta'] ) ? (string) $row['trashed_meta'] : '';
864 $decoded = '' !== $meta_raw ? json_decode( $meta_raw, true ) : null;
865 $ancestry = ( is_array( $decoded ) && isset( $decoded['ancestry'] ) && is_array( $decoded['ancestry'] ) )
866 ? $decoded['ancestry']
867 : array();
868 if ( ! empty( $ancestry ) ) {
869 $folder_placement_row = $wpdb->get_row(
870 $wpdb->prepare(
871 "SELECT id, parent_id FROM {$tables['placements']}
872 WHERE file_type = 'folder' AND file_ref = %s
873 ORDER BY id ASC LIMIT 1",
874 (string) $folder_id
875 ),
876 ARRAY_A
877 );
878 if ( $folder_placement_row ) {
879 $origin_parent = (int) $folder_placement_row['parent_id'];
880 $alive = $origin_parent > 0
881 ? desktop_mode_files_get_folder( $origin_parent, true )
882 : null;
883 if ( $origin_parent > 0 && ! $alive ) {
884 $resolved = desktop_mode_files_resurrect_ancestry( $user_id, $ancestry );
885 $wpdb->update(
886 $tables['placements'],
887 array(
888 'parent_id' => $resolved,
889 'updated_at_ms' => $now,
890 ),
891 array( 'id' => (int) $folder_placement_row['id'] ),
892 array( '%d', '%d' ),
893 array( '%d' )
894 );
895 }
896 }
897 }
898 // Restore placements that this folder's trash had cascaded.
899 $wpdb->query(
900 $wpdb->prepare(
901 "UPDATE {$tables['placements']}
902 SET trashed_at_ms = NULL,
903 trashed_by = NULL,
904 trashed_via_folder = NULL,
905 trashed_meta = NULL,
906 updated_at_ms = %d
907 WHERE trashed_via_folder = %d",
908 $now,
909 $folder_id
910 )
911 );
912 // Recursively restore nested folders captured in the snapshot.
913 foreach ( (array) $nested_ids as $nid ) {
914 desktop_mode_files_restore_folder( $user_id, (int) $nid );
915 }
916
917 // Enforce the "tombstones never refer to alive rows" invariant
918 // across the restored cohort: the folder itself, every cascade-
919 // restored placement that lived inside it, and every nested
920 // folder recursed into above already clears its own. Here we
921 // scrub the FOLDER's own tombstones plus those of every cascade-
922 // restored placement so a fresh heartbeat tick can't surface
923 // them as `removed.*` against the now-alive rows.
924 desktop_mode_files_clear_tombstones_for( 'folder', $folder_id );
925 $restored_placement_ids = $wpdb->get_col(
926 $wpdb->prepare(
927 "SELECT id FROM {$tables['placements']}
928 WHERE trashed_via_folder IS NULL
929 AND ( parent_id = %d OR ( file_type = 'folder' AND file_ref = %s ) )",
930 $folder_id,
931 (string) $folder_id
932 )
933 );
934 foreach ( (array) $restored_placement_ids as $rpid ) {
935 desktop_mode_files_clear_tombstones_for( 'placement', (int) $rpid );
936 }
937
938 /**
939 * @since 0.8.0
940 *
941 * @param int $folder_id
942 * @param int $user_id
943 */
944 do_action( 'desktop_mode_files_after_restore_folder', $folder_id, $user_id );
945
946 return true;
947 }
948
949 /**
950 * Permanently delete a trashed folder and all its trashed-via-
951 * cascade child placements. Independent placements that landed in
952 * the trash separately stay there.
953 *
954 * @since 0.8.0
955 *
956 * @param int $user_id
957 * @param int $folder_id
958 * @return true|WP_Error
959 */
960 function desktop_mode_files_purge_folder( $user_id, $folder_id ) {
961 global $wpdb;
962 $user_id = (int) $user_id;
963 $folder_id = (int) $folder_id;
964 $tables = desktop_mode_files_table_names();
965
966 $row = $wpdb->get_row(
967 $wpdb->prepare(
968 "SELECT * FROM {$tables['folders']} WHERE id = %d",
969 $folder_id
970 ),
971 ARRAY_A
972 );
973 if ( ! $row ) {
974 return true;
975 }
976 if ( ! desktop_mode_files_user_can_purge_folder( $user_id, $row ) ) {
977 return new WP_Error(
978 'desktop_mode_files_forbidden',
979 __( 'You do not have permission to delete this folder.', 'desktop-mode' ),
980 array( 'status' => 403 )
981 );
982 }
983
984 /**
985 * @since 0.8.0
986 *
987 * @param int $folder_id
988 * @param int $user_id
989 * @param array $row
990 */
991 do_action( 'desktop_mode_files_before_purge_folder', $folder_id, $user_id, $row );
992
993 // Cascade-revoke every share + per-user decision for the folder
994 // BEFORE deleting the folder row. Without this, purge left
995 // orphan `folder_shares` + `share_user_decisions` rows pointing
996 // at a folder id that no longer exists — `compute_visible_folders`
997 // would still join them, and the row leak grew with every
998 // recycle-bin empty. Mirrors the same cleanup
999 // `desktop_mode_files_delete_folder_recursive` does for the
1000 // "delete from desktop" path.
1001 $share_ids = (array) $wpdb->get_col(
1002 $wpdb->prepare(
1003 "SELECT id FROM {$tables['shares']} WHERE folder_id = %d",
1004 $folder_id
1005 )
1006 );
1007 if ( ! empty( $share_ids ) ) {
1008 $placeholders = implode( ',', array_fill( 0, count( $share_ids ), '%d' ) );
1009 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1010 $wpdb->query(
1011 $wpdb->prepare(
1012 "DELETE FROM {$tables['decisions']} WHERE share_id IN ($placeholders)",
1013 $share_ids
1014 )
1015 );
1016 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1017 $wpdb->query(
1018 $wpdb->prepare(
1019 "DELETE FROM {$tables['shares']} WHERE id IN ($placeholders)",
1020 $share_ids
1021 )
1022 );
1023 }
1024
1025 // Drop every placement that points AT this folder (recipients'
1026 // root tiles + the owner's own), with tombstones so connected
1027 // clients scrub the tile via the heartbeat.
1028 $pointing_ids = (array) $wpdb->get_col(
1029 $wpdb->prepare(
1030 "SELECT id FROM {$tables['placements']}
1031 WHERE file_type = 'folder' AND file_ref = %s",
1032 (string) $folder_id
1033 )
1034 );
1035 foreach ( $pointing_ids as $pid ) {
1036 desktop_mode_files_write_tombstone( 'placement', (int) $pid );
1037 }
1038 if ( ! empty( $pointing_ids ) ) {
1039 $placeholders = implode( ',', array_fill( 0, count( $pointing_ids ), '%d' ) );
1040 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1041 $wpdb->query(
1042 $wpdb->prepare(
1043 "DELETE FROM {$tables['placements']} WHERE id IN ($placeholders)",
1044 $pointing_ids
1045 )
1046 );
1047 }
1048
1049 $wpdb->delete(
1050 $tables['placements'],
1051 array( 'trashed_via_folder' => $folder_id ),
1052 array( '%d' )
1053 );
1054 $wpdb->delete( $tables['folders'], array( 'id' => $folder_id ), array( '%d' ) );
1055
1056 /**
1057 * @since 0.8.0
1058 *
1059 * @param int $folder_id
1060 * @param int $user_id
1061 */
1062 do_action( 'desktop_mode_files_after_purge_folder', $folder_id, $user_id );
1063
1064 return true;
1065 }
1066
1067 /* ================================================================== *
1068 * Recycle-bin list builder.
1069 * ================================================================== */
1070
1071 /**
1072 * Count of trashed placements + folders surfaced to the recycle bin
1073 * for `$user_id`. Mirrors `_list_trashed_for_recycle_bin`'s "skip
1074 * cascaded children" rule so the badge matches the visible list.
1075 *
1076 * @since 0.8.0
1077 *
1078 * @param int $user_id Owner.
1079 * @return int
1080 */
1081 function desktop_mode_files_count_trashed_for_recycle_bin( $user_id ) {
1082 global $wpdb;
1083 $user_id = (int) $user_id;
1084 if ( $user_id <= 0 ) {
1085 return 0;
1086 }
1087 $tables = desktop_mode_files_table_names();
1088
1089 $placements = (int) $wpdb->get_var(
1090 $wpdb->prepare(
1091 "SELECT COUNT(*) FROM {$tables['placements']}
1092 WHERE owner_id = %d
1093 AND trashed_at_ms IS NOT NULL
1094 AND trashed_via_folder IS NULL",
1095 $user_id
1096 )
1097 );
1098 $folders = (int) $wpdb->get_var(
1099 $wpdb->prepare(
1100 "SELECT COUNT(*) FROM {$tables['folders']}
1101 WHERE owner_id = %d AND trashed_at_ms IS NOT NULL",
1102 $user_id
1103 )
1104 );
1105 return $placements + $folders;
1106 }
1107
1108 /**
1109 * Return the trashed placements + folders for a user, shaped as
1110 * recycle-bin items. Used by the recycle bin's REST list endpoint
1111 * to merge files-on-the-desktop trash with the WP-core trash.
1112 *
1113 * @since 0.8.0
1114 *
1115 * @param int $user_id Owner.
1116 * @return array[] List of recycle-bin item shapes.
1117 */
1118 function desktop_mode_files_list_trashed_for_recycle_bin( $user_id ) {
1119 global $wpdb;
1120 $user_id = (int) $user_id;
1121 $tables = desktop_mode_files_table_names();
1122 $out = array();
1123
1124 // Trashed placements owned by this user.
1125 $placements = $wpdb->get_results(
1126 $wpdb->prepare(
1127 "SELECT * FROM {$tables['placements']}
1128 WHERE owner_id = %d AND trashed_at_ms IS NOT NULL
1129 ORDER BY trashed_at_ms DESC",
1130 $user_id
1131 ),
1132 ARRAY_A
1133 );
1134 foreach ( (array) $placements as $row ) {
1135 // Skip cascaded children — the parent folder represents
1136 // the whole bundle in the recycle bin.
1137 if ( ! empty( $row['trashed_via_folder'] ) ) {
1138 continue;
1139 }
1140 $file = function_exists( 'desktop_mode_resolve_file' )
1141 ? desktop_mode_resolve_file( $row['file_type'], $row['file_ref'] )
1142 : null;
1143 $title = $file ? (string) $file->title() : (string) $row['file_type'];
1144 $icon = $file ? (string) $file->icon() : 'dashicons-no-alt';
1145 // Two recycle-bin buckets:
1146 // - `shortcut` → plugin-registered icons (file_type='shortcut')
1147 // - `placement` → every other placement (post / page /
1148 // attachment / user / term / comment / …)
1149 // Lets the bin's type-filter tabs split "Shortcuts" from
1150 // "Files" without overloading either label.
1151 $bucket = ( 'shortcut' === (string) $row['file_type'] )
1152 ? 'shortcut'
1153 : 'placement';
1154 $subtitle = ( 'shortcut' === $bucket )
1155 ? __( 'Desktop shortcut', 'desktop-mode' )
1156 : sprintf(
1157 /* translators: %s: file-type slug like 'post', 'attachment'. */
1158 __( '%s on desktop', 'desktop-mode' ),
1159 (string) $row['file_type']
1160 );
1161 // `type_label` is the short uppercase badge the JS renders
1162 // inline before the title. Most placements collapse to the
1163 // generic "Placement" badge (the JS humanizes the bucket
1164 // slug when no label is set). `link` placements — created
1165 // via "New URL" on the desktop — deserve a more specific
1166 // label so they read as URL-shortcuts, not generic tiles.
1167 $item = array(
1168 'id' => (int) $row['id'],
1169 'type' => $bucket,
1170 'title' => $title,
1171 'subtitle' => $subtitle,
1172 'mime' => '',
1173 'preview' => $file ? (string) $file->preview_url() : '',
1174 'icon' => $icon,
1175 'deleted_at' => gmdate( 'c', (int) round( (int) $row['trashed_at_ms'] / 1000 ) ),
1176 'deleted_by' => '',
1177 'deleted_by_id' => (int) $row['trashed_by'],
1178 'can_restore' => desktop_mode_files_user_can_restore_placement( $user_id, $row ),
1179 'can_purge' => desktop_mode_files_user_can_purge_placement( $user_id, $row ),
1180 'edit_link' => '',
1181 );
1182 if ( 'link' === (string) $row['file_type'] ) {
1183 $item['type_label'] = __( 'URL', 'desktop-mode' );
1184 }
1185 $out[] = $item;
1186 }
1187
1188 // Trashed folders owned by this user.
1189 $folders = $wpdb->get_results(
1190 $wpdb->prepare(
1191 "SELECT * FROM {$tables['folders']}
1192 WHERE owner_id = %d AND trashed_at_ms IS NOT NULL
1193 ORDER BY trashed_at_ms DESC",
1194 $user_id
1195 ),
1196 ARRAY_A
1197 );
1198 foreach ( (array) $folders as $row ) {
1199 $child_count = (int) $wpdb->get_var(
1200 $wpdb->prepare(
1201 "SELECT COUNT(*) FROM {$tables['placements']}
1202 WHERE trashed_via_folder = %d",
1203 (int) $row['id']
1204 )
1205 );
1206 $out[] = array(
1207 'id' => (int) $row['id'],
1208 'type' => 'folder',
1209 'title' => (string) $row['name'],
1210 'subtitle' => $child_count > 0
1211 ? sprintf(
1212 /* translators: %d: number of items inside the trashed folder. */
1213 _n( 'Folder · %d item inside', 'Folder · %d items inside', $child_count, 'desktop-mode' ),
1214 $child_count
1215 )
1216 : __( 'Folder · empty', 'desktop-mode' ),
1217 'mime' => '',
1218 'preview' => '',
1219 'icon' => 'dashicons-portfolio',
1220 'deleted_at' => gmdate( 'c', (int) round( (int) $row['trashed_at_ms'] / 1000 ) ),
1221 'deleted_by' => '',
1222 'deleted_by_id' => (int) $row['trashed_by'],
1223 'can_restore' => desktop_mode_files_user_can_restore_folder( $user_id, $row ),
1224 'can_purge' => desktop_mode_files_user_can_purge_folder( $user_id, $row ),
1225 'edit_link' => '',
1226 );
1227 }
1228
1229 // Resolve display-name for the deleted-by id once per user.
1230 $user_cache = array();
1231 foreach ( $out as &$item ) {
1232 $uid = (int) $item['deleted_by_id'];
1233 if ( $uid <= 0 ) {
1234 continue;
1235 }
1236 if ( ! isset( $user_cache[ $uid ] ) ) {
1237 $u = get_userdata( $uid );
1238 $user_cache[ $uid ] = $u ? $u->display_name : '';
1239 }
1240 $item['deleted_by'] = $user_cache[ $uid ];
1241 }
1242 unset( $item );
1243
1244 return $out;
1245 }
1246