PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.8
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 0.8.6 All 33 releases
desktop-mode / includes / desktop-files / trash.php

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

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