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

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

964 lines 31.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 placement store.
4 *
5 * CRUD primitives for the `_desktop_mode_file_placements` table.
6 * Every read goes through `desktop_mode_files_query_args` so
7 * plugins can scope what's visible (mirror of the recycle-bin's
8 * filter pattern). Every write fires before / after actions so
9 * other plugins can react and so Phase 6's Heartbeat sync has a
10 * single subscription point.
11 *
12 * Capability gate is per-call: callers pass the `$user_id` they
13 * intend to act for; the function consults
14 * `desktop_mode_files_can_place` (filter) and the file's
15 * `Desktop_Mode_File::can_read()` before writing.
16 *
17 * Tombstones are written for every successful remove / move-out
18 * so the Phase-6 Heartbeat delta knows what to send.
19 *
20 * @package WPDesktopMode
21 * @since 0.9.0
22 */
23
24 defined( 'ABSPATH' ) || exit;
25
26 /**
27 * Insert a placement.
28 *
29 * @since 0.9.0
30 *
31 * @param int $user_id Owner of the placement (the user the
32 * tile lives on).
33 * @param int $parent_id Folder id, or 0 for the desktop root.
34 * @param string $type File-type slug.
35 * @param string $ref Entity reference.
36 * @param array $args Optional. `x`, `y`, `sort_order`, `meta`.
37 * @return int|WP_Error Placement id on success, `WP_Error` otherwise.
38 */
39 function desktop_mode_files_place( $user_id, $parent_id, $type, $ref, $args = array() ) {
40 global $wpdb;
41
42 $user_id = (int) $user_id;
43 $parent_id = (int) $parent_id;
44 $type = (string) $type;
45 $ref = (string) $ref;
46
47 if ( $user_id <= 0 ) {
48 return new WP_Error( 'desktop_mode_files_invalid_user', __( 'A user id is required.', 'desktop-mode' ), array( 'status' => 400 ) );
49 }
50 $entry = desktop_mode_get_file_type( $type );
51 if ( ! $entry ) {
52 return new WP_Error( 'desktop_mode_files_unknown_type', __( 'Unknown file type.', 'desktop-mode' ), array( 'status' => 400 ) );
53 }
54
55 /**
56 * Gate placement creation. Defaults to allowing the user to
57 * place any type they can read; plugins use this to enforce
58 * stricter rules (e.g. only admins may place users).
59 *
60 * @since 0.9.0
61 *
62 * @param bool $can Default: file's `can_read( $user_id )`.
63 * @param int $user_id Owner.
64 * @param string $type File-type slug.
65 * @param string $ref Entity reference.
66 */
67 $file = desktop_mode_resolve_file( $type, $ref );
68 $can = $file ? $file->can_read( $user_id ) : false;
69 $can = (bool) apply_filters( 'desktop_mode_files_can_place', $can, $user_id, $type, $ref );
70 if ( ! $can ) {
71 return new WP_Error( 'desktop_mode_files_forbidden', __( 'You are not allowed to place this file.', 'desktop-mode' ), array( 'status' => 403 ) );
72 }
73
74 // Write-gate: placing INTO a non-owned folder requires the
75 // folder's `write` cap. Owner / desktop-root placements are
76 // always allowed.
77 if ( (int) $parent_id > 0 ) {
78 $target_folder = desktop_mode_files_get_folder( (int) $parent_id );
79 if ( $target_folder && (int) $target_folder['owner_id'] !== $user_id ) {
80 $cap = function_exists( 'desktop_mode_folder_share_user_capability' )
81 ? desktop_mode_folder_share_user_capability( (int) $parent_id, $user_id )
82 : 'none';
83 if ( 'write' !== $cap ) {
84 return new WP_Error(
85 'desktop_mode_files_no_write_in_shared_folder',
86 __( 'You only have read access to that folder.', 'desktop-mode' ),
87 array( 'status' => 403 )
88 );
89 }
90 }
91 }
92
93 $args = wp_parse_args(
94 $args,
95 array(
96 'x' => 0,
97 'y' => 0,
98 'sort_order' => 0,
99 'meta' => null,
100 )
101 );
102
103 $tables = desktop_mode_files_table_names();
104 $now = desktop_mode_files_now_ms();
105 $row = array(
106 'owner_id' => $user_id,
107 'updated_by' => $user_id,
108 'parent_id' => max( 0, $parent_id ),
109 'file_type' => $type,
110 'file_ref' => $ref,
111 'x' => (int) $args['x'],
112 'y' => (int) $args['y'],
113 'sort_order' => (int) $args['sort_order'],
114 'updated_at_ms' => $now,
115 'meta' => null === $args['meta'] ? null : wp_json_encode( $args['meta'] ),
116 );
117
118 // Silence wpdb's HTML error block around the insert: a unique-key
119 // collision is an expected (and recovered) outcome below, and the
120 // default `WP_DEBUG_DISPLAY` behavior would otherwise prepend a
121 // `<div class="wpdberror">…</div>` to the REST response body and
122 // break `await response.json()` on the client. `$wpdb->last_error`
123 // still holds the message, so genuine DB failures surface via the
124 // `WP_Error` we return when no existing row is found.
125 $prev_suppress = $wpdb->suppress_errors( true );
126 $ok = $wpdb->insert( $tables['placements'], $row, array( '%d', '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%d', '%s' ) );
127 $wpdb->suppress_errors( $prev_suppress );
128 if ( false === $ok ) {
129 // Disambiguate the two cases hidden behind a generic `false`:
130 // (a) The `placement_unique` index (since 0.8.0) collided
131 // with an existing row for this (user, parent, type,
132 // ref). The collider may be active (the orphan placer
133 // won a race against this caller, or a stale duplicate
134 // client request) or soft-trashed (the user removed a
135 // link tile and is now recreating the same URL).
136 // (b) Any other DB failure — connection, deadlock, bad
137 // column. The error must surface to the caller as-is.
138 // We treat (a) idempotently: restore if trashed, then apply
139 // the caller's coords / meta so the new placement lands
140 // where the user clicked. Reported as #167.
141 $existing = $wpdb->get_row(
142 $wpdb->prepare(
143 "SELECT * FROM {$tables['placements']}
144 WHERE owner_id = %d
145 AND parent_id = %d
146 AND file_type = %s
147 AND file_ref = %s
148 LIMIT 1",
149 $user_id,
150 max( 0, $parent_id ),
151 $type,
152 $ref
153 ),
154 ARRAY_A
155 );
156 if ( ! $existing ) {
157 return new WP_Error( 'desktop_mode_files_insert_failed', __( 'Failed to write placement.', 'desktop-mode' ), array( 'status' => 500 ) );
158 }
159
160 $existing_id = (int) $existing['id'];
161
162 if ( ! empty( $existing['trashed_at_ms'] ) ) {
163 $restore = desktop_mode_files_restore_placement( $user_id, $existing_id );
164 if ( is_wp_error( $restore ) ) {
165 return $restore;
166 }
167 }
168
169 // Belt-and-suspenders: even on the non-trashed-revival branch
170 // (caller re-placed an already-active row at new coords), any
171 // stale tombstones for this id should be cleared so a fresh
172 // heartbeat tick can't surface "alive + removed" together.
173 // `restore_placement` already clears its own tombstones, so
174 // this is a no-op in the soft-trashed branch above.
175 desktop_mode_files_clear_tombstones_for( 'placement', $existing_id );
176
177 $move = desktop_mode_files_move(
178 $existing_id,
179 $user_id,
180 array(
181 'parent_id' => max( 0, $parent_id ),
182 'x' => (int) $args['x'],
183 'y' => (int) $args['y'],
184 'sort_order' => (int) $args['sort_order'],
185 'meta' => $args['meta'],
186 )
187 );
188 if ( is_wp_error( $move ) ) {
189 return $move;
190 }
191
192 return $existing_id;
193 }
194 $id = (int) $wpdb->insert_id;
195
196 $row['id'] = $id;
197
198 /**
199 * Fires after a placement is created.
200 *
201 * @since 0.9.0
202 *
203 * @param int $id Placement id.
204 * @param array $row Inserted row.
205 */
206 do_action( 'desktop_mode_file_placed', $id, $row );
207
208 return $id;
209 }
210
211 /**
212 * Move / mutate a placement. Pass `null` for fields that should
213 * stay untouched.
214 *
215 * @since 0.9.0
216 *
217 * @param int $placement_id Placement id.
218 * @param int $user_id Acting user (for capability gate).
219 * @param array $changes `parent_id`, `x`, `y`, `sort_order`, `meta`.
220 * @return true|WP_Error
221 */
222 function desktop_mode_files_move( $placement_id, $user_id, $changes = array() ) {
223 global $wpdb;
224
225 $placement_id = (int) $placement_id;
226 $user_id = (int) $user_id;
227 if ( $placement_id <= 0 || $user_id <= 0 ) {
228 return new WP_Error( 'desktop_mode_files_bad_request', __( 'Invalid arguments.', 'desktop-mode' ), array( 'status' => 400 ) );
229 }
230
231 $row = desktop_mode_files_get_placement( $placement_id );
232 if ( ! $row ) {
233 return new WP_Error( 'desktop_mode_files_not_found', __( 'Placement not found.', 'desktop-mode' ), array( 'status' => 404 ) );
234 }
235
236 // Permission check. Owner of the row is always allowed. For
237 // rows inside a shared folder, the FOLDER's write cap is the
238 // gate — anyone with write on the folder can move/rearrange
239 // every icon in it, regardless of which user originally placed
240 // the row (shared-namespace semantics).
241 $is_row_owner = (int) $row['owner_id'] === $user_id;
242 if ( (int) $row['parent_id'] > 0 ) {
243 $source_folder = desktop_mode_files_get_folder( (int) $row['parent_id'] );
244 if ( $source_folder ) {
245 $is_folder_owner = (int) $source_folder['owner_id'] === $user_id;
246 $source_cap = function_exists( 'desktop_mode_folder_share_user_capability' )
247 ? desktop_mode_folder_share_user_capability( (int) $row['parent_id'], $user_id )
248 : 'none';
249 if ( ! $is_row_owner && ! $is_folder_owner && 'write' !== $source_cap ) {
250 return new WP_Error(
251 'desktop_mode_files_no_write_in_shared_folder',
252 __( 'You only have read access to this folder.', 'desktop-mode' ),
253 array( 'status' => 403 )
254 );
255 }
256 // Folder reader on their own row inside the folder — still no.
257 if ( $is_row_owner && ! $is_folder_owner && 'write' !== $source_cap ) {
258 return new WP_Error(
259 'desktop_mode_files_no_write_in_shared_folder',
260 __( 'You only have read access to this folder.', 'desktop-mode' ),
261 array( 'status' => 403 )
262 );
263 }
264 }
265 } elseif ( ! $is_row_owner ) {
266 // Row at root, viewer doesn't own it.
267 return new WP_Error( 'desktop_mode_files_forbidden', __( 'You cannot edit this placement.', 'desktop-mode' ), array( 'status' => 403 ) );
268 }
269 if ( isset( $changes['parent_id'] ) ) {
270 $target_parent = max( 0, (int) $changes['parent_id'] );
271 if ( $target_parent > 0 ) {
272 $target = desktop_mode_files_get_folder( $target_parent );
273 if ( $target && (int) $target['owner_id'] !== $user_id ) {
274 $cap = function_exists( 'desktop_mode_folder_share_user_capability' )
275 ? desktop_mode_folder_share_user_capability( $target_parent, $user_id )
276 : 'none';
277 if ( 'write' !== $cap ) {
278 return new WP_Error(
279 'desktop_mode_files_no_write_in_shared_folder',
280 __( 'You only have read access to that folder.', 'desktop-mode' ),
281 array( 'status' => 403 )
282 );
283 }
284 }
285 }
286 // Folder-cycle guard. When the row being moved is itself a
287 // folder placement, the new parent must not be the folder
288 // itself OR any of the folder's descendants — otherwise we
289 // commit `X.parent_id = Y` while `Y.parent_id` still leads
290 // back through `X`, producing an unreachable cycle that
291 // strands every descendant outside the desktop root. Walk
292 // the ancestry of `$target_parent` upward; bail if we hit
293 // the moving folder's id or detect a pre-existing cycle.
294 if ( 'folder' === (string) $row['file_type'] && $target_parent > 0 ) {
295 $moving_folder_id = (int) $row['file_ref'];
296 if ( $moving_folder_id > 0 ) {
297 if (
298 desktop_mode_files_would_create_folder_cycle(
299 $user_id,
300 $moving_folder_id,
301 $target_parent
302 )
303 ) {
304 return new WP_Error(
305 'desktop_mode_files_folder_cycle',
306 __( 'A folder cannot be placed inside itself or one of its descendants.', 'desktop-mode' ),
307 array( 'status' => 409 )
308 );
309 }
310 }
311 }
312 }
313
314 $tables = desktop_mode_files_table_names();
315 $set = array();
316 $fmt = array();
317
318 if ( isset( $changes['parent_id'] ) ) {
319 $set['parent_id'] = max( 0, (int) $changes['parent_id'] );
320 $fmt[] = '%d';
321 }
322 foreach ( array( 'x', 'y', 'sort_order' ) as $col ) {
323 if ( isset( $changes[ $col ] ) ) {
324 $set[ $col ] = (int) $changes[ $col ];
325 $fmt[] = '%d';
326 }
327 }
328 if ( array_key_exists( 'meta', $changes ) ) {
329 $set['meta'] = null === $changes['meta'] ? null : wp_json_encode( $changes['meta'] );
330 $fmt[] = '%s';
331 }
332 if ( empty( $set ) ) {
333 return true; // No-op.
334 }
335
336 $set['updated_at_ms'] = desktop_mode_files_now_ms();
337 $fmt[] = '%d';
338 // Track who actually fired this mutation so a future
339 // `If-Match` 409 can name the session that won the race,
340 // not just whoever happens to own the row.
341 $set['updated_by'] = $user_id;
342 $fmt[] = '%d';
343
344 $ok = $wpdb->update( $tables['placements'], $set, array( 'id' => $placement_id ), $fmt, array( '%d' ) );
345 if ( false === $ok ) {
346 return new WP_Error( 'desktop_mode_files_update_failed', __( 'Failed to update placement.', 'desktop-mode' ), array( 'status' => 500 ) );
347 }
348
349 $next = desktop_mode_files_get_placement( $placement_id );
350
351 /**
352 * Fires after a placement is moved / mutated.
353 *
354 * @since 0.9.0
355 *
356 * @param int $id Placement id.
357 * @param array $next Row after the change.
358 * @param array $prev Row before the change.
359 */
360 do_action( 'desktop_mode_file_moved', $placement_id, $next, $row );
361
362 return true;
363 }
364
365 /**
366 * Remove a placement. Writes a tombstone for Phase-6 sync.
367 *
368 * @since 0.9.0
369 *
370 * @param int $placement_id Placement id.
371 * @param int $user_id Acting user.
372 * @return true|WP_Error
373 */
374 function desktop_mode_files_remove( $placement_id, $user_id ) {
375 global $wpdb;
376
377 $placement_id = (int) $placement_id;
378 $user_id = (int) $user_id;
379 $row = desktop_mode_files_get_placement( $placement_id );
380 if ( ! $row ) {
381 return new WP_Error( 'desktop_mode_files_not_found', __( 'Placement not found.', 'desktop-mode' ), array( 'status' => 404 ) );
382 }
383 // Same shared-namespace rule as the trash gate: owner of the
384 // row OR write cap on the parent folder.
385 $is_row_owner = (int) $row['owner_id'] === $user_id;
386 $allowed = $is_row_owner;
387 if ( ! $allowed && (int) $row['parent_id'] > 0 ) {
388 $cap = function_exists( 'desktop_mode_folder_share_user_capability' )
389 ? desktop_mode_folder_share_user_capability( (int) $row['parent_id'], $user_id )
390 : 'none';
391 $allowed = 'write' === $cap;
392 }
393 if ( ! $allowed ) {
394 return new WP_Error( 'desktop_mode_files_forbidden', __( 'You cannot remove this placement.', 'desktop-mode' ), array( 'status' => 403 ) );
395 }
396
397 $tables = desktop_mode_files_table_names();
398 $ok = $wpdb->delete( $tables['placements'], array( 'id' => $placement_id ), array( '%d' ) );
399 if ( false === $ok ) {
400 return new WP_Error( 'desktop_mode_files_delete_failed', __( 'Failed to remove placement.', 'desktop-mode' ), array( 'status' => 500 ) );
401 }
402
403 desktop_mode_files_write_tombstone( 'placement', $placement_id );
404
405 /**
406 * Fires after a placement is removed.
407 *
408 * @since 0.9.0
409 *
410 * @param int $id Placement id.
411 * @param array $row Removed row.
412 */
413 do_action( 'desktop_mode_file_unplaced', $placement_id, $row );
414
415 return true;
416 }
417
418 /**
419 * Read a single placement row by id.
420 *
421 * @since 0.9.0
422 *
423 * @param int $placement_id Placement id.
424 * @return array|null
425 */
426 function desktop_mode_files_get_placement( $placement_id ) {
427 global $wpdb;
428 $tables = desktop_mode_files_table_names();
429 $row = $wpdb->get_row(
430 $wpdb->prepare( "SELECT * FROM {$tables['placements']} WHERE id = %d", (int) $placement_id ),
431 ARRAY_A
432 );
433 if ( ! $row ) {
434 return null;
435 }
436 return desktop_mode_files_normalize_placement_row( $row );
437 }
438
439 /**
440 * List placements for a user under a given folder (0 = desktop
441 * root). Honors the `desktop_mode_files_query_args` filter and
442 * applies the file-type's `can_read()` per row.
443 *
444 * @since 0.9.0
445 *
446 * @param int $user_id Viewer.
447 * @param int $parent_id Folder id (0 for desktop root).
448 * @return array[]
449 */
450 function desktop_mode_files_get_for_user_folder( $user_id, $parent_id = 0 ) {
451 global $wpdb;
452 $user_id = (int) $user_id;
453 $parent_id = max( 0, (int) $parent_id );
454 if ( $user_id <= 0 ) {
455 return array();
456 }
457
458 $tables = desktop_mode_files_table_names();
459
460 // Access gate + shared-namespace decision for non-root folders.
461 // Desktop root (parent_id = 0) is always per-user. For sub-
462 // folders, the contents of a SHARED folder are visible to every
463 // user who has at least 'read' on it — the icons inside belong
464 // to the folder, not to the user who originally placed them.
465 $share_view = false;
466 if ( $parent_id > 0 ) {
467 $folder = desktop_mode_files_get_folder( $parent_id );
468 if ( ! $folder ) {
469 return array();
470 }
471 if ( (int) $folder['owner_id'] !== $user_id ) {
472 $cap = function_exists( 'desktop_mode_folder_share_user_capability' )
473 ? desktop_mode_folder_share_user_capability( $parent_id, $user_id )
474 : 'none';
475 if ( 'none' === $cap ) {
476 return array();
477 }
478 $share_view = true;
479 }
480 }
481
482 $args = array(
483 'user_id' => $user_id,
484 'parent_id' => $parent_id,
485 'share_view' => $share_view,
486 );
487 /**
488 * Filter the args used to read placements.
489 *
490 * @since 0.9.0
491 *
492 * @param array $args Defaults: `{ user_id, parent_id, share_view }`.
493 * @param int $user_id Viewer.
494 * @param int $parent_id Folder id.
495 */
496 $args = (array) apply_filters( 'desktop_mode_files_query_args', $args, $user_id, $parent_id );
497
498 // Active queries always exclude trashed rows. Recycle-bin
499 // callers reach for the dedicated trash store.
500 if ( ! empty( $args['share_view'] ) ) {
501 // Shared sub-folder — return every placement in the folder
502 // regardless of which user originally placed it. The icons
503 // are part of the folder; the owner_id column is audit info,
504 // not a permission gate.
505 $rows = $wpdb->get_results(
506 $wpdb->prepare(
507 "SELECT * FROM {$tables['placements']}
508 WHERE parent_id = %d
509 AND trashed_at_ms IS NULL
510 ORDER BY sort_order ASC, id ASC",
511 (int) $args['parent_id']
512 ),
513 ARRAY_A
514 );
515 } else {
516 $rows = $wpdb->get_results(
517 $wpdb->prepare(
518 "SELECT * FROM {$tables['placements']}
519 WHERE owner_id = %d
520 AND parent_id = %d
521 AND trashed_at_ms IS NULL
522 ORDER BY sort_order ASC, id ASC",
523 (int) $args['user_id'],
524 (int) $args['parent_id']
525 ),
526 ARRAY_A
527 );
528 }
529 if ( ! is_array( $rows ) ) {
530 return array();
531 }
532 $out = array();
533 foreach ( $rows as $row ) {
534 $normalized = desktop_mode_files_normalize_placement_row( $row );
535 $file = desktop_mode_resolve_file( $normalized['file_type'], $normalized['file_ref'] );
536 if ( empty( $args['share_view'] ) ) {
537 // Private folder / desktop root — keep the existing
538 // per-row read filter so stale/inaccessible entities
539 // don't clutter the user's own view.
540 if ( $file && ! $file->can_read( $user_id ) ) {
541 continue;
542 }
543 } else {
544 // Shared folder view — every placement the OWNER chose
545 // to include is surfaced to the recipient. When the
546 // recipient lacks read on the underlying entity, we
547 // mark the row as `access_gated` so the tile renderer
548 // can paint a lock overlay + tooltip + intercept the
549 // open. Entity-level access enforcement still happens
550 // at open time in each opener — this flag is just the
551 // pre-emptive visual cue.
552 if ( $file && ! $file->can_read( $user_id ) ) {
553 $normalized['access_gated'] = true;
554 }
555 }
556 $out[] = $normalized;
557 }
558 return $out;
559 }
560
561 /**
562 * Self-healing backfill. Surfaces two kinds of orphans on the
563 * desktop root:
564 *
565 * 1. Folders the viewer owns that have no placement anywhere.
566 * (Pre-fix folder-create flow could leak these; new flow
567 * writes the placement atomically.)
568 *
569 * 2. Plugin shortcuts (`desktop_mode_register_icon()`) the
570 * viewer hasn't placed yet. The unified-rail merge means
571 * every registered icon shows up as a `shortcut` placement
572 * on first hydrate so plugin shortcuts behave like any
573 * other tile (drag, sort, right-click, clean up).
574 *
575 * Idempotent on both axes: a folder/shortcut that already has
576 * any placement is left alone. Coordinates use the column-major
577 * grid that `src/desktop-files/grid.ts` mirrors on the JS side.
578 *
579 * Called by the placements list endpoint when the requested
580 * folder is the root (`parent_id=0`).
581 *
582 * @since 0.9.0
583 *
584 * @param int $user_id Viewer.
585 * @return int Total number of orphans that were auto-placed.
586 */
587 function desktop_mode_files_auto_place_orphans( $user_id ) {
588 global $wpdb;
589 $user_id = (int) $user_id;
590 if ( $user_id <= 0 ) {
591 return 0;
592 }
593
594 $tables = desktop_mode_files_table_names();
595
596 // 1) Owned folders without any placement. Skip trashed folders
597 // and trashed placement rows so a recycled folder doesn't get
598 // auto-placed back on the desktop on next hydrate.
599 $folder_rows = $wpdb->get_results(
600 $wpdb->prepare(
601 "SELECT f.id FROM {$tables['folders']} f
602 LEFT JOIN {$tables['placements']} p
603 ON p.file_type = 'folder'
604 AND p.file_ref = CAST( f.id AS CHAR )
605 AND p.trashed_at_ms IS NULL
606 WHERE f.owner_id = %d
607 AND f.trashed_at_ms IS NULL
608 AND p.id IS NULL",
609 $user_id
610 ),
611 ARRAY_A
612 );
613
614 // 2) Registered plugin shortcuts the viewer hasn't placed yet.
615 // Pull the registered ids first, then ask the placements
616 // table which the viewer already has — set difference
617 // yields the orphans without a heavy join.
618 $shortcut_ids = array();
619 $registry = function_exists( 'desktop_mode_desktop_icon_registry' )
620 ? desktop_mode_desktop_icon_registry()
621 : array();
622 if ( is_array( $registry ) ) {
623 // Run through the same `desktop_mode_icons` filter the
624 // build-payload path uses so plugins (and tests) can inject
625 // virtual entries.
626 $registry = (array) apply_filters( 'desktop_mode_icons', $registry );
627 }
628 if ( is_array( $registry ) && ! empty( $registry ) ) {
629 $registered_ids = array_map( 'strval', array_keys( $registry ) );
630 $placeholders = implode( ',', array_fill( 0, count( $registered_ids ), '%s' ) );
631 $args = array_merge( array( $user_id ), $registered_ids );
632 $placed_ids = $wpdb->get_col(
633 $wpdb->prepare(
634 "SELECT file_ref FROM {$tables['placements']}
635 WHERE owner_id = %d
636 AND file_type = 'shortcut'
637 AND trashed_at_ms IS NULL
638 AND file_ref IN ($placeholders)",
639 $args
640 )
641 );
642 $placed_set = array_flip( array_map( 'strval', (array) $placed_ids ) );
643 foreach ( $registered_ids as $id ) {
644 if ( ! isset( $placed_set[ $id ] ) ) {
645 $shortcut_ids[] = $id;
646 }
647 }
648 }
649
650 if ( empty( $folder_rows ) && empty( $shortcut_ids ) ) {
651 return 0;
652 }
653
654 // Build an occupied set from EXISTING root placements so
655 // we never drop an orphan on top of a tile the user
656 // already has. Cell math mirrors `src/desktop-files/grid.ts`
657 // (padding 16 + col 96 + row 110).
658 $existing = $wpdb->get_results(
659 $wpdb->prepare(
660 "SELECT x, y FROM {$tables['placements']}
661 WHERE owner_id = %d
662 AND parent_id = 0
663 AND trashed_at_ms IS NULL",
664 $user_id
665 ),
666 ARRAY_A
667 );
668 $occupied = array();
669 foreach ( (array) $existing as $row ) {
670 $col = max( 0, (int) round( ( (int) $row['x'] - 16 ) / 96 ) );
671 $row_idx = max( 0, (int) round( ( (int) $row['y'] - 16 ) / 110 ) );
672 $occupied[ "$col,$row_idx" ] = true;
673 }
674
675 $find_next = function () use ( &$occupied ) {
676 for ( $col = 0; $col < 999; $col++ ) {
677 for ( $row = 0; $row < 999; $row++ ) {
678 $key = "$col,$row";
679 if ( ! isset( $occupied[ $key ] ) ) {
680 $occupied[ $key ] = true;
681 return array( $col, $row );
682 }
683 }
684 }
685 return array( 0, 0 );
686 };
687
688 $placed = 0;
689 $emit_at = function ( $type, $ref, $col, $row ) use ( $user_id, &$occupied, &$placed ) {
690 $occupied[ "$col,$row" ] = true;
691 $result = desktop_mode_files_place(
692 $user_id,
693 0,
694 $type,
695 (string) $ref,
696 array(
697 'x' => 16 + $col * 96,
698 'y' => 16 + $row * 110,
699 )
700 );
701 if ( ! is_wp_error( $result ) ) {
702 $placed++;
703 }
704 };
705 $emit_next = function ( $type, $ref ) use ( $find_next, $emit_at ) {
706 list( $col, $row ) = $find_next();
707 $emit_at( $type, $ref, $col, $row );
708 };
709
710 // Pinned shortcuts get reserved top-left slots. Anchored to
711 // column 0 (x=16) so the JS layer's pinned-slot math
712 // (`GRID_PADDING + n*GRID_CELL_H`) lines up with the row the
713 // server picks. Mark the slot occupied BEFORE other orphans
714 // flow in so a draggable tile never lands on top of the
715 // anchored "My WordPress" icon.
716 $pinned_ids = array();
717 foreach ( $shortcut_ids as $id ) {
718 $entry = is_array( $registry ) && isset( $registry[ $id ] ) ? $registry[ $id ] : null;
719 if ( is_array( $entry ) && ! empty( $entry['pinned'] ) ) {
720 $pinned_ids[] = $id;
721 }
722 }
723 $pinned_set = array_flip( $pinned_ids );
724 $pinned_idx = 0;
725 foreach ( $pinned_ids as $id ) {
726 // Force the slot at (col=0, row=$pinned_idx). Any pre-
727 // existing occupant on that slot is left alone — the layer
728 // re-renders the pinned tile on top via the
729 // client-side override anyway, but a future cleanup pass
730 // can compact the column.
731 $occupied[ "0,$pinned_idx" ] = true;
732 $emit_at( 'shortcut', $id, 0, $pinned_idx );
733 $pinned_idx++;
734 }
735
736 foreach ( $folder_rows as $row ) {
737 $emit_next( 'folder', $row['id'] );
738 }
739 foreach ( $shortcut_ids as $id ) {
740 if ( isset( $pinned_set[ $id ] ) ) {
741 continue;
742 }
743 $emit_next( 'shortcut', $id );
744 }
745 return $placed;
746 }
747
748 /**
749 * Backwards-compat alias for the older folder-only name.
750 *
751 * @deprecated 0.9.0 Use {@see desktop_mode_files_auto_place_orphans}.
752 *
753 * @param int $user_id Viewer.
754 * @return int
755 */
756 function desktop_mode_files_auto_place_orphan_folders( $user_id ) {
757 return desktop_mode_files_auto_place_orphans( $user_id );
758 }
759
760 /**
761 * Coerce wpdb's stringly-typed row into typed values + decoded
762 * meta. Internal helper.
763 *
764 * @since 0.9.0
765 * @internal
766 *
767 * @param array $row Raw wpdb row.
768 * @return array
769 */
770 function desktop_mode_files_normalize_placement_row( $row ) {
771 $meta_raw = isset( $row['meta'] ) ? (string) $row['meta'] : '';
772 $meta = '' !== $meta_raw ? json_decode( $meta_raw, true ) : null;
773 return array(
774 'id' => (int) $row['id'],
775 'owner_id' => (int) $row['owner_id'],
776 // `updated_by` is v10. Null on legacy rows — callers that
777 // need the actor (e.g. `desktop_mode_files_check_if_match`)
778 // fall back to `owner_id` when this is null/missing.
779 'updated_by' => isset( $row['updated_by'] ) ? (int) $row['updated_by'] : null,
780 'parent_id' => (int) $row['parent_id'],
781 'file_type' => (string) $row['file_type'],
782 'file_ref' => (string) $row['file_ref'],
783 'x' => (int) $row['x'],
784 'y' => (int) $row['y'],
785 'sort_order' => (int) $row['sort_order'],
786 'updated_at_ms' => (int) $row['updated_at_ms'],
787 'meta' => is_array( $meta ) ? $meta : null,
788 );
789 }
790
791 /**
792 * Write a tombstone row.
793 *
794 * Invariant (enforced by callers): tombstones may exist only for
795 * ids of PERMANENTLY-DELETED rows. Never write one for a soft-
796 * trashed row — soft-trash is reversible and the heartbeat already
797 * surfaces it via the `trashed_at_ms IS NOT NULL` query in
798 * `desktop_mode_files_compute_heartbeat_delta`. A tombstone on a
799 * soft-trashed row lingers past restore and tells clients the row
800 * is gone while it is in fact alive — see the "shared folder
801 * disappears on refresh" bug fixed in 0.18.x.
802 *
803 * Pair every revival path (`desktop_mode_files_restore_placement`,
804 * `desktop_mode_files_restore_folder`, and the duplicate-key
805 * revival branch in `desktop_mode_files_place`) with
806 * {@see desktop_mode_files_clear_tombstones_for} so a row coming
807 * back to life never carries lingering tombstones from a previous
808 * removal that turned out to be reversible.
809 *
810 * @since 0.9.0
811 *
812 * @param string $kind 'placement' | 'folder'.
813 * @param int $ref Removed id.
814 */
815 function desktop_mode_files_write_tombstone( $kind, $ref ) {
816 global $wpdb;
817 $tables = desktop_mode_files_table_names();
818 $wpdb->insert(
819 $tables['tombstones'],
820 array(
821 'kind' => (string) $kind,
822 'ref_id' => (int) $ref,
823 'removed_at_ms' => desktop_mode_files_now_ms(),
824 ),
825 array( '%s', '%d', '%d' )
826 );
827 }
828
829 /**
830 * Drop every tombstone referring to `($kind, $ref_id)`. Called from
831 * the row-revival paths so a placement/folder coming back to life
832 * never carries lingering "this is gone" tombstones from a
833 * previous removal that turned out to be reversible.
834 *
835 * Idempotent — running it on a ref with no tombstones is a no-op.
836 *
837 * @since 0.18.0
838 *
839 * @param string $kind 'placement' | 'folder'.
840 * @param int $ref_id Row id whose tombstones should be dropped.
841 */
842 function desktop_mode_files_clear_tombstones_for( $kind, $ref_id ) {
843 global $wpdb;
844 $ref_id = (int) $ref_id;
845 if ( $ref_id <= 0 ) {
846 return;
847 }
848 $tables = desktop_mode_files_table_names();
849 $wpdb->delete(
850 $tables['tombstones'],
851 array(
852 'kind' => (string) $kind,
853 'ref_id' => $ref_id,
854 ),
855 array( '%s', '%d' )
856 );
857 }
858
859 /**
860 * Daily prune of tombstones older than 7 days. Phase 6 may tune
861 * the retention window when the Heartbeat sync lands; for now 7d
862 * is plenty since a client that's been offline that long will
863 * always need a full REST resync anyway.
864 *
865 * @since 0.9.0
866 */
867 function desktop_mode_files_prune_tombstones() {
868 global $wpdb;
869 $tables = desktop_mode_files_table_names();
870 $cutoff = desktop_mode_files_now_ms() - ( 7 * DAY_IN_SECONDS * 1000 );
871 $wpdb->query( $wpdb->prepare( "DELETE FROM {$tables['tombstones']} WHERE removed_at_ms < %d", $cutoff ) );
872 }
873 add_action( 'desktop_mode_files_daily_prune', 'desktop_mode_files_prune_tombstones' );
874
875 /**
876 * Schedule the daily prune on activation.
877 *
878 * @since 0.9.0
879 */
880 function desktop_mode_files_schedule_prune() {
881 if ( ! wp_next_scheduled( 'desktop_mode_files_daily_prune' ) ) {
882 wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'desktop_mode_files_daily_prune' );
883 }
884 }
885 add_action( 'init', 'desktop_mode_files_schedule_prune' );
886
887 /**
888 * Walk the folder-parentage chain upward from `$target_parent_id` and
889 * return `true` when `$moving_folder_id` appears anywhere in it —
890 * meaning a move that sets `moving_folder.parent_id = target_parent`
891 * would produce an unreachable cycle (folder placed inside itself or
892 * inside one of its own descendants).
893 *
894 * Folder-parentage is determined by the parent_id of the folder's
895 * placement row, not by anything on the `folders` table. We look up
896 * one live placement per cursor (`LIMIT 1`) — folders with multiple
897 * placements (rare; shared semantics) are still safely covered
898 * because any one upward chain hitting the moving folder is enough
899 * to flag the cycle.
900 *
901 * Defends against pre-existing cycles in the data: if we re-visit a
902 * cursor we've already seen, we treat it as a cycle and reject, so a
903 * corrupted history can't drive this function into an infinite loop.
904 *
905 * @since 0.20.0
906 *
907 * @param int $user_id Acting user.
908 * @param int $moving_folder_id Folder being moved (its `folders.id`).
909 * @param int $target_parent_id New container folder id (0 = desktop root).
910 * @return bool True when the move would create a cycle.
911 */
912 function desktop_mode_files_would_create_folder_cycle( $user_id, $moving_folder_id, $target_parent_id ) {
913 $moving_folder_id = (int) $moving_folder_id;
914 $target_parent_id = (int) $target_parent_id;
915 $user_id = (int) $user_id;
916 if ( $moving_folder_id <= 0 || $target_parent_id <= 0 || $user_id <= 0 ) {
917 return false;
918 }
919 if ( $moving_folder_id === $target_parent_id ) {
920 return true;
921 }
922 global $wpdb;
923 $tables = desktop_mode_files_table_names();
924 $visited = array();
925 $cursor = $target_parent_id;
926 // Hard cap to defend against catastrophically deep trees too —
927 // real installs won't approach 256.
928 $max_depth = 256;
929 while ( $cursor > 0 && $max_depth-- > 0 ) {
930 if ( $cursor === $moving_folder_id ) {
931 return true;
932 }
933 if ( isset( $visited[ $cursor ] ) ) {
934 // Pre-existing cycle in the data — bail safe by treating
935 // the move as cycle-creating too. Better to refuse a
936 // suspicious move than to deepen the damage.
937 return true;
938 }
939 $visited[ $cursor ] = true;
940 // `LIMIT 1` is enough — any upward chain that reaches the
941 // moving folder flags the cycle. Trashed rows excluded so a
942 // recycled-then-recovered ancestor doesn't poison the check.
943 $parent_of_cursor = $wpdb->get_var(
944 $wpdb->prepare(
945 "SELECT parent_id FROM {$tables['placements']}
946 WHERE owner_id = %d
947 AND file_type = 'folder'
948 AND file_ref = %s
949 AND trashed_at_ms IS NULL
950 LIMIT 1",
951 $user_id,
952 (string) $cursor
953 )
954 );
955 if ( null === $parent_of_cursor ) {
956 // Folder has no live placement under this user — chain
957 // ends here. No cycle.
958 return false;
959 }
960 $cursor = (int) $parent_of_cursor;
961 }
962 return false;
963 }
964