400 ) );
}
$entry = desktop_mode_get_file_type( $type );
if ( ! $entry ) {
return new WP_Error( 'desktop_mode_files_unknown_type', __( 'Unknown file type.', 'desktop-mode' ), array( 'status' => 400 ) );
}
/**
* Gate placement creation. Defaults to allowing the user to
* place any type they can read; plugins use this to enforce
* stricter rules (e.g. only admins may place users).
*
* @since 0.9.0
*
* @param bool $can Default: file's `can_read( $user_id )`.
* @param int $user_id Owner.
* @param string $type File-type slug.
* @param string $ref Entity reference.
*/
$file = desktop_mode_resolve_file( $type, $ref );
$can = $file ? $file->can_read( $user_id ) : false;
$can = (bool) apply_filters( 'desktop_mode_files_can_place', $can, $user_id, $type, $ref );
if ( ! $can ) {
return new WP_Error( 'desktop_mode_files_forbidden', __( 'You are not allowed to place this file.', 'desktop-mode' ), array( 'status' => 403 ) );
}
// Write-gate: placing INTO a non-owned folder requires the
// folder's `write` cap. Owner / desktop-root placements are
// always allowed.
if ( (int) $parent_id > 0 ) {
$target_folder = desktop_mode_files_get_folder( (int) $parent_id );
if ( $target_folder && (int) $target_folder['owner_id'] !== $user_id ) {
$cap = function_exists( 'desktop_mode_folder_share_user_capability' )
? desktop_mode_folder_share_user_capability( (int) $parent_id, $user_id )
: 'none';
if ( 'write' !== $cap ) {
return new WP_Error(
'desktop_mode_files_no_write_in_shared_folder',
__( 'You only have read access to that folder.', 'desktop-mode' ),
array( 'status' => 403 )
);
}
}
}
$args = wp_parse_args(
$args,
array(
'x' => 0,
'y' => 0,
'sort_order' => 0,
'meta' => null,
)
);
$tables = desktop_mode_files_table_names();
$now = desktop_mode_files_now_ms();
$row = array(
'owner_id' => $user_id,
'updated_by' => $user_id,
'parent_id' => max( 0, $parent_id ),
'file_type' => $type,
'file_ref' => $ref,
'x' => (int) $args['x'],
'y' => (int) $args['y'],
'sort_order' => (int) $args['sort_order'],
'updated_at_ms' => $now,
'meta' => null === $args['meta'] ? null : wp_json_encode( $args['meta'] ),
);
// Silence wpdb's HTML error block around the insert: a unique-key
// collision is an expected (and recovered) outcome below, and the
// default `WP_DEBUG_DISPLAY` behavior would otherwise prepend a
// `
…
` to the REST response body and
// break `await response.json()` on the client. `$wpdb->last_error`
// still holds the message, so genuine DB failures surface via the
// `WP_Error` we return when no existing row is found.
$prev_suppress = $wpdb->suppress_errors( true );
$ok = $wpdb->insert( $tables['placements'], $row, array( '%d', '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%d', '%s' ) );
$wpdb->suppress_errors( $prev_suppress );
if ( false === $ok ) {
// Disambiguate the two cases hidden behind a generic `false`:
// (a) The `placement_unique` index (since 0.8.0) collided
// with an existing row for this (user, parent, type,
// ref). The collider may be active (the orphan placer
// won a race against this caller, or a stale duplicate
// client request) or soft-trashed (the user removed a
// link tile and is now recreating the same URL).
// (b) Any other DB failure — connection, deadlock, bad
// column. The error must surface to the caller as-is.
// We treat (a) idempotently: restore if trashed, then apply
// the caller's coords / meta so the new placement lands
// where the user clicked. Reported as #167.
$existing = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$tables['placements']}
WHERE owner_id = %d
AND parent_id = %d
AND file_type = %s
AND file_ref = %s
LIMIT 1",
$user_id,
max( 0, $parent_id ),
$type,
$ref
),
ARRAY_A
);
if ( ! $existing ) {
return new WP_Error( 'desktop_mode_files_insert_failed', __( 'Failed to write placement.', 'desktop-mode' ), array( 'status' => 500 ) );
}
$existing_id = (int) $existing['id'];
if ( ! empty( $existing['trashed_at_ms'] ) ) {
$restore = desktop_mode_files_restore_placement( $user_id, $existing_id );
if ( is_wp_error( $restore ) ) {
return $restore;
}
}
// Belt-and-suspenders: even on the non-trashed-revival branch
// (caller re-placed an already-active row at new coords), any
// stale tombstones for this id should be cleared so a fresh
// heartbeat tick can't surface "alive + removed" together.
// `restore_placement` already clears its own tombstones, so
// this is a no-op in the soft-trashed branch above.
desktop_mode_files_clear_tombstones_for( 'placement', $existing_id );
$move = desktop_mode_files_move(
$existing_id,
$user_id,
array(
'parent_id' => max( 0, $parent_id ),
'x' => (int) $args['x'],
'y' => (int) $args['y'],
'sort_order' => (int) $args['sort_order'],
'meta' => $args['meta'],
)
);
if ( is_wp_error( $move ) ) {
return $move;
}
return $existing_id;
}
$id = (int) $wpdb->insert_id;
$row['id'] = $id;
/**
* Fires after a placement is created.
*
* @since 0.9.0
*
* @param int $id Placement id.
* @param array $row Inserted row.
*/
do_action( 'desktop_mode_file_placed', $id, $row );
return $id;
}
/**
* Move / mutate a placement. Pass `null` for fields that should
* stay untouched.
*
* @since 0.9.0
*
* @param int $placement_id Placement id.
* @param int $user_id Acting user (for capability gate).
* @param array $changes `parent_id`, `x`, `y`, `sort_order`, `meta`.
* @return true|WP_Error
*/
function desktop_mode_files_move( $placement_id, $user_id, $changes = array() ) {
global $wpdb;
$placement_id = (int) $placement_id;
$user_id = (int) $user_id;
if ( $placement_id <= 0 || $user_id <= 0 ) {
return new WP_Error( 'desktop_mode_files_bad_request', __( 'Invalid arguments.', 'desktop-mode' ), array( 'status' => 400 ) );
}
$row = desktop_mode_files_get_placement( $placement_id );
if ( ! $row ) {
return new WP_Error( 'desktop_mode_files_not_found', __( 'Placement not found.', 'desktop-mode' ), array( 'status' => 404 ) );
}
// Permission check. Owner of the row is always allowed. For
// rows inside a shared folder, the FOLDER's write cap is the
// gate — anyone with write on the folder can move/rearrange
// every icon in it, regardless of which user originally placed
// the row (shared-namespace semantics).
$is_row_owner = (int) $row['owner_id'] === $user_id;
if ( (int) $row['parent_id'] > 0 ) {
$source_folder = desktop_mode_files_get_folder( (int) $row['parent_id'] );
if ( $source_folder ) {
$is_folder_owner = (int) $source_folder['owner_id'] === $user_id;
$source_cap = function_exists( 'desktop_mode_folder_share_user_capability' )
? desktop_mode_folder_share_user_capability( (int) $row['parent_id'], $user_id )
: 'none';
if ( ! $is_row_owner && ! $is_folder_owner && 'write' !== $source_cap ) {
return new WP_Error(
'desktop_mode_files_no_write_in_shared_folder',
__( 'You only have read access to this folder.', 'desktop-mode' ),
array( 'status' => 403 )
);
}
// Folder reader on their own row inside the folder — still no.
if ( $is_row_owner && ! $is_folder_owner && 'write' !== $source_cap ) {
return new WP_Error(
'desktop_mode_files_no_write_in_shared_folder',
__( 'You only have read access to this folder.', 'desktop-mode' ),
array( 'status' => 403 )
);
}
}
} elseif ( ! $is_row_owner ) {
// Row at root, viewer doesn't own it.
return new WP_Error( 'desktop_mode_files_forbidden', __( 'You cannot edit this placement.', 'desktop-mode' ), array( 'status' => 403 ) );
}
if ( isset( $changes['parent_id'] ) ) {
$target_parent = max( 0, (int) $changes['parent_id'] );
if ( $target_parent > 0 ) {
$target = desktop_mode_files_get_folder( $target_parent );
if ( $target && (int) $target['owner_id'] !== $user_id ) {
$cap = function_exists( 'desktop_mode_folder_share_user_capability' )
? desktop_mode_folder_share_user_capability( $target_parent, $user_id )
: 'none';
if ( 'write' !== $cap ) {
return new WP_Error(
'desktop_mode_files_no_write_in_shared_folder',
__( 'You only have read access to that folder.', 'desktop-mode' ),
array( 'status' => 403 )
);
}
}
}
// Folder-cycle guard. When the row being moved is itself a
// folder placement, the new parent must not be the folder
// itself OR any of the folder's descendants — otherwise we
// commit `X.parent_id = Y` while `Y.parent_id` still leads
// back through `X`, producing an unreachable cycle that
// strands every descendant outside the desktop root. Walk
// the ancestry of `$target_parent` upward; bail if we hit
// the moving folder's id or detect a pre-existing cycle.
if ( 'folder' === (string) $row['file_type'] && $target_parent > 0 ) {
$moving_folder_id = (int) $row['file_ref'];
if ( $moving_folder_id > 0 ) {
if (
desktop_mode_files_would_create_folder_cycle(
$user_id,
$moving_folder_id,
$target_parent
)
) {
return new WP_Error(
'desktop_mode_files_folder_cycle',
__( 'A folder cannot be placed inside itself or one of its descendants.', 'desktop-mode' ),
array( 'status' => 409 )
);
}
}
}
}
$tables = desktop_mode_files_table_names();
$set = array();
$fmt = array();
if ( isset( $changes['parent_id'] ) ) {
$set['parent_id'] = max( 0, (int) $changes['parent_id'] );
$fmt[] = '%d';
}
foreach ( array( 'x', 'y', 'sort_order' ) as $col ) {
if ( isset( $changes[ $col ] ) ) {
$set[ $col ] = (int) $changes[ $col ];
$fmt[] = '%d';
}
}
if ( array_key_exists( 'meta', $changes ) ) {
$set['meta'] = null === $changes['meta'] ? null : wp_json_encode( $changes['meta'] );
$fmt[] = '%s';
}
if ( empty( $set ) ) {
return true; // No-op.
}
$set['updated_at_ms'] = desktop_mode_files_now_ms();
$fmt[] = '%d';
// Track who actually fired this mutation so a future
// `If-Match` 409 can name the session that won the race,
// not just whoever happens to own the row.
$set['updated_by'] = $user_id;
$fmt[] = '%d';
$ok = $wpdb->update( $tables['placements'], $set, array( 'id' => $placement_id ), $fmt, array( '%d' ) );
if ( false === $ok ) {
return new WP_Error( 'desktop_mode_files_update_failed', __( 'Failed to update placement.', 'desktop-mode' ), array( 'status' => 500 ) );
}
$next = desktop_mode_files_get_placement( $placement_id );
/**
* Fires after a placement is moved / mutated.
*
* @since 0.9.0
*
* @param int $id Placement id.
* @param array $next Row after the change.
* @param array $prev Row before the change.
*/
do_action( 'desktop_mode_file_moved', $placement_id, $next, $row );
return true;
}
/**
* Remove a placement. Writes a tombstone for Phase-6 sync.
*
* @since 0.9.0
*
* @param int $placement_id Placement id.
* @param int $user_id Acting user.
* @return true|WP_Error
*/
function desktop_mode_files_remove( $placement_id, $user_id ) {
global $wpdb;
$placement_id = (int) $placement_id;
$user_id = (int) $user_id;
$row = desktop_mode_files_get_placement( $placement_id );
if ( ! $row ) {
return new WP_Error( 'desktop_mode_files_not_found', __( 'Placement not found.', 'desktop-mode' ), array( 'status' => 404 ) );
}
// Same shared-namespace rule as the trash gate: owner of the
// row OR write cap on the parent folder.
$is_row_owner = (int) $row['owner_id'] === $user_id;
$allowed = $is_row_owner;
if ( ! $allowed && (int) $row['parent_id'] > 0 ) {
$cap = function_exists( 'desktop_mode_folder_share_user_capability' )
? desktop_mode_folder_share_user_capability( (int) $row['parent_id'], $user_id )
: 'none';
$allowed = 'write' === $cap;
}
if ( ! $allowed ) {
return new WP_Error( 'desktop_mode_files_forbidden', __( 'You cannot remove this placement.', 'desktop-mode' ), array( 'status' => 403 ) );
}
$tables = desktop_mode_files_table_names();
$ok = $wpdb->delete( $tables['placements'], array( 'id' => $placement_id ), array( '%d' ) );
if ( false === $ok ) {
return new WP_Error( 'desktop_mode_files_delete_failed', __( 'Failed to remove placement.', 'desktop-mode' ), array( 'status' => 500 ) );
}
desktop_mode_files_write_tombstone( 'placement', $placement_id );
/**
* Fires after a placement is removed.
*
* @since 0.9.0
*
* @param int $id Placement id.
* @param array $row Removed row.
*/
do_action( 'desktop_mode_file_unplaced', $placement_id, $row );
return true;
}
/**
* Read a single placement row by id.
*
* @since 0.9.0
*
* @param int $placement_id Placement id.
* @return array|null
*/
function desktop_mode_files_get_placement( $placement_id ) {
global $wpdb;
$tables = desktop_mode_files_table_names();
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$tables['placements']} WHERE id = %d", (int) $placement_id ),
ARRAY_A
);
if ( ! $row ) {
return null;
}
return desktop_mode_files_normalize_placement_row( $row );
}
/**
* List placements for a user under a given folder (0 = desktop
* root). Honors the `desktop_mode_files_query_args` filter and
* applies the file-type's `can_read()` per row.
*
* @since 0.9.0
*
* @param int $user_id Viewer.
* @param int $parent_id Folder id (0 for desktop root).
* @return array[]
*/
function desktop_mode_files_get_for_user_folder( $user_id, $parent_id = 0 ) {
global $wpdb;
$user_id = (int) $user_id;
$parent_id = max( 0, (int) $parent_id );
if ( $user_id <= 0 ) {
return array();
}
$tables = desktop_mode_files_table_names();
// Access gate + shared-namespace decision for non-root folders.
// Desktop root (parent_id = 0) is always per-user. For sub-
// folders, the contents of a SHARED folder are visible to every
// user who has at least 'read' on it — the icons inside belong
// to the folder, not to the user who originally placed them.
$share_view = false;
if ( $parent_id > 0 ) {
$folder = desktop_mode_files_get_folder( $parent_id );
if ( ! $folder ) {
return array();
}
if ( (int) $folder['owner_id'] !== $user_id ) {
$cap = function_exists( 'desktop_mode_folder_share_user_capability' )
? desktop_mode_folder_share_user_capability( $parent_id, $user_id )
: 'none';
if ( 'none' === $cap ) {
return array();
}
$share_view = true;
}
}
$args = array(
'user_id' => $user_id,
'parent_id' => $parent_id,
'share_view' => $share_view,
);
/**
* Filter the args used to read placements.
*
* @since 0.9.0
*
* @param array $args Defaults: `{ user_id, parent_id, share_view }`.
* @param int $user_id Viewer.
* @param int $parent_id Folder id.
*/
$args = (array) apply_filters( 'desktop_mode_files_query_args', $args, $user_id, $parent_id );
// Active queries always exclude trashed rows. Recycle-bin
// callers reach for the dedicated trash store.
if ( ! empty( $args['share_view'] ) ) {
// Shared sub-folder — return every placement in the folder
// regardless of which user originally placed it. The icons
// are part of the folder; the owner_id column is audit info,
// not a permission gate.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$tables['placements']}
WHERE parent_id = %d
AND trashed_at_ms IS NULL
ORDER BY sort_order ASC, id ASC",
(int) $args['parent_id']
),
ARRAY_A
);
} else {
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$tables['placements']}
WHERE owner_id = %d
AND parent_id = %d
AND trashed_at_ms IS NULL
ORDER BY sort_order ASC, id ASC",
(int) $args['user_id'],
(int) $args['parent_id']
),
ARRAY_A
);
}
if ( ! is_array( $rows ) ) {
return array();
}
$out = array();
foreach ( $rows as $row ) {
$normalized = desktop_mode_files_normalize_placement_row( $row );
$file = desktop_mode_resolve_file( $normalized['file_type'], $normalized['file_ref'] );
if ( empty( $args['share_view'] ) ) {
// Private folder / desktop root — keep the existing
// per-row read filter so stale/inaccessible entities
// don't clutter the user's own view.
if ( $file && ! $file->can_read( $user_id ) ) {
continue;
}
} else {
// Shared folder view — every placement the OWNER chose
// to include is surfaced to the recipient. When the
// recipient lacks read on the underlying entity, we
// mark the row as `access_gated` so the tile renderer
// can paint a lock overlay + tooltip + intercept the
// open. Entity-level access enforcement still happens
// at open time in each opener — this flag is just the
// pre-emptive visual cue.
if ( $file && ! $file->can_read( $user_id ) ) {
$normalized['access_gated'] = true;
}
}
$out[] = $normalized;
}
return $out;
}
/**
* Self-healing backfill. Surfaces two kinds of orphans on the
* desktop root:
*
* 1. Folders the viewer owns that have no placement anywhere.
* (Pre-fix folder-create flow could leak these; new flow
* writes the placement atomically.)
*
* 2. Plugin shortcuts (`desktop_mode_register_icon()`) the
* viewer hasn't placed yet. The unified-rail merge means
* every registered icon shows up as a `shortcut` placement
* on first hydrate so plugin shortcuts behave like any
* other tile (drag, sort, right-click, clean up).
*
* Idempotent on both axes: a folder/shortcut that already has
* any placement is left alone. Coordinates use the column-major
* grid that `src/desktop-files/grid.ts` mirrors on the JS side.
*
* Called by the placements list endpoint when the requested
* folder is the root (`parent_id=0`).
*
* @since 0.9.0
*
* @param int $user_id Viewer.
* @return int Total number of orphans that were auto-placed.
*/
function desktop_mode_files_auto_place_orphans( $user_id ) {
global $wpdb;
$user_id = (int) $user_id;
if ( $user_id <= 0 ) {
return 0;
}
$tables = desktop_mode_files_table_names();
// 1) Owned folders without any placement. Skip trashed folders
// and trashed placement rows so a recycled folder doesn't get
// auto-placed back on the desktop on next hydrate.
$folder_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT f.id FROM {$tables['folders']} f
LEFT JOIN {$tables['placements']} p
ON p.file_type = 'folder'
AND p.file_ref = CAST( f.id AS CHAR )
AND p.trashed_at_ms IS NULL
WHERE f.owner_id = %d
AND f.trashed_at_ms IS NULL
AND p.id IS NULL",
$user_id
),
ARRAY_A
);
// 2) Registered plugin shortcuts the viewer hasn't placed yet.
// Pull the registered ids first, then ask the placements
// table which the viewer already has — set difference
// yields the orphans without a heavy join.
$shortcut_ids = array();
$registry = function_exists( 'desktop_mode_desktop_icon_registry' )
? desktop_mode_desktop_icon_registry()
: array();
if ( is_array( $registry ) ) {
// Run through the same `desktop_mode_icons` filter the
// build-payload path uses so plugins (and tests) can inject
// virtual entries.
$registry = (array) apply_filters( 'desktop_mode_icons', $registry );
}
if ( is_array( $registry ) && ! empty( $registry ) ) {
$registered_ids = array_map( 'strval', array_keys( $registry ) );
$placeholders = implode( ',', array_fill( 0, count( $registered_ids ), '%s' ) );
$args = array_merge( array( $user_id ), $registered_ids );
$placed_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT file_ref FROM {$tables['placements']}
WHERE owner_id = %d
AND file_type = 'shortcut'
AND trashed_at_ms IS NULL
AND file_ref IN ($placeholders)",
$args
)
);
$placed_set = array_flip( array_map( 'strval', (array) $placed_ids ) );
foreach ( $registered_ids as $id ) {
if ( ! isset( $placed_set[ $id ] ) ) {
$shortcut_ids[] = $id;
}
}
}
if ( empty( $folder_rows ) && empty( $shortcut_ids ) ) {
return 0;
}
// Build an occupied set from EXISTING root placements so
// we never drop an orphan on top of a tile the user
// already has. Cell math mirrors `src/desktop-files/grid.ts`
// (padding 16 + col 96 + row 110).
$existing = $wpdb->get_results(
$wpdb->prepare(
"SELECT x, y FROM {$tables['placements']}
WHERE owner_id = %d
AND parent_id = 0
AND trashed_at_ms IS NULL",
$user_id
),
ARRAY_A
);
$occupied = array();
foreach ( (array) $existing as $row ) {
$col = max( 0, (int) round( ( (int) $row['x'] - 16 ) / 96 ) );
$row_idx = max( 0, (int) round( ( (int) $row['y'] - 16 ) / 110 ) );
$occupied[ "$col,$row_idx" ] = true;
}
$find_next = function () use ( &$occupied ) {
for ( $col = 0; $col < 999; $col++ ) {
for ( $row = 0; $row < 999; $row++ ) {
$key = "$col,$row";
if ( ! isset( $occupied[ $key ] ) ) {
$occupied[ $key ] = true;
return array( $col, $row );
}
}
}
return array( 0, 0 );
};
$placed = 0;
$emit_at = function ( $type, $ref, $col, $row ) use ( $user_id, &$occupied, &$placed ) {
$occupied[ "$col,$row" ] = true;
$result = desktop_mode_files_place(
$user_id,
0,
$type,
(string) $ref,
array(
'x' => 16 + $col * 96,
'y' => 16 + $row * 110,
)
);
if ( ! is_wp_error( $result ) ) {
$placed++;
}
};
$emit_next = function ( $type, $ref ) use ( $find_next, $emit_at ) {
list( $col, $row ) = $find_next();
$emit_at( $type, $ref, $col, $row );
};
// Pinned shortcuts get reserved top-left slots. Anchored to
// column 0 (x=16) so the JS layer's pinned-slot math
// (`GRID_PADDING + n*GRID_CELL_H`) lines up with the row the
// server picks. Mark the slot occupied BEFORE other orphans
// flow in so a draggable tile never lands on top of the
// anchored "My WordPress" icon.
$pinned_ids = array();
foreach ( $shortcut_ids as $id ) {
$entry = is_array( $registry ) && isset( $registry[ $id ] ) ? $registry[ $id ] : null;
if ( is_array( $entry ) && ! empty( $entry['pinned'] ) ) {
$pinned_ids[] = $id;
}
}
$pinned_set = array_flip( $pinned_ids );
$pinned_idx = 0;
foreach ( $pinned_ids as $id ) {
// Force the slot at (col=0, row=$pinned_idx). Any pre-
// existing occupant on that slot is left alone — the layer
// re-renders the pinned tile on top via the
// client-side override anyway, but a future cleanup pass
// can compact the column.
$occupied[ "0,$pinned_idx" ] = true;
$emit_at( 'shortcut', $id, 0, $pinned_idx );
$pinned_idx++;
}
foreach ( $folder_rows as $row ) {
$emit_next( 'folder', $row['id'] );
}
foreach ( $shortcut_ids as $id ) {
if ( isset( $pinned_set[ $id ] ) ) {
continue;
}
$emit_next( 'shortcut', $id );
}
return $placed;
}
/**
* Backwards-compat alias for the older folder-only name.
*
* @deprecated 0.9.0 Use {@see desktop_mode_files_auto_place_orphans}.
*
* @param int $user_id Viewer.
* @return int
*/
function desktop_mode_files_auto_place_orphan_folders( $user_id ) {
return desktop_mode_files_auto_place_orphans( $user_id );
}
/**
* Coerce wpdb's stringly-typed row into typed values + decoded
* meta. Internal helper.
*
* @since 0.9.0
* @internal
*
* @param array $row Raw wpdb row.
* @return array
*/
function desktop_mode_files_normalize_placement_row( $row ) {
$meta_raw = isset( $row['meta'] ) ? (string) $row['meta'] : '';
$meta = '' !== $meta_raw ? json_decode( $meta_raw, true ) : null;
return array(
'id' => (int) $row['id'],
'owner_id' => (int) $row['owner_id'],
// `updated_by` is v10. Null on legacy rows — callers that
// need the actor (e.g. `desktop_mode_files_check_if_match`)
// fall back to `owner_id` when this is null/missing.
'updated_by' => isset( $row['updated_by'] ) ? (int) $row['updated_by'] : null,
'parent_id' => (int) $row['parent_id'],
'file_type' => (string) $row['file_type'],
'file_ref' => (string) $row['file_ref'],
'x' => (int) $row['x'],
'y' => (int) $row['y'],
'sort_order' => (int) $row['sort_order'],
'updated_at_ms' => (int) $row['updated_at_ms'],
'meta' => is_array( $meta ) ? $meta : null,
);
}
/**
* Write a tombstone row.
*
* Invariant (enforced by callers): tombstones may exist only for
* ids of PERMANENTLY-DELETED rows. Never write one for a soft-
* trashed row — soft-trash is reversible and the heartbeat already
* surfaces it via the `trashed_at_ms IS NOT NULL` query in
* `desktop_mode_files_compute_heartbeat_delta`. A tombstone on a
* soft-trashed row lingers past restore and tells clients the row
* is gone while it is in fact alive — see the "shared folder
* disappears on refresh" bug fixed in 0.18.x.
*
* Pair every revival path (`desktop_mode_files_restore_placement`,
* `desktop_mode_files_restore_folder`, and the duplicate-key
* revival branch in `desktop_mode_files_place`) with
* {@see desktop_mode_files_clear_tombstones_for} so a row coming
* back to life never carries lingering tombstones from a previous
* removal that turned out to be reversible.
*
* @since 0.9.0
*
* @param string $kind 'placement' | 'folder'.
* @param int $ref Removed id.
*/
function desktop_mode_files_write_tombstone( $kind, $ref ) {
global $wpdb;
$tables = desktop_mode_files_table_names();
$wpdb->insert(
$tables['tombstones'],
array(
'kind' => (string) $kind,
'ref_id' => (int) $ref,
'removed_at_ms' => desktop_mode_files_now_ms(),
),
array( '%s', '%d', '%d' )
);
}
/**
* Drop every tombstone referring to `($kind, $ref_id)`. Called from
* the row-revival paths so a placement/folder coming back to life
* never carries lingering "this is gone" tombstones from a
* previous removal that turned out to be reversible.
*
* Idempotent — running it on a ref with no tombstones is a no-op.
*
* @since 0.18.0
*
* @param string $kind 'placement' | 'folder'.
* @param int $ref_id Row id whose tombstones should be dropped.
*/
function desktop_mode_files_clear_tombstones_for( $kind, $ref_id ) {
global $wpdb;
$ref_id = (int) $ref_id;
if ( $ref_id <= 0 ) {
return;
}
$tables = desktop_mode_files_table_names();
$wpdb->delete(
$tables['tombstones'],
array(
'kind' => (string) $kind,
'ref_id' => $ref_id,
),
array( '%s', '%d' )
);
}
/**
* Daily prune of tombstones older than 7 days. Phase 6 may tune
* the retention window when the Heartbeat sync lands; for now 7d
* is plenty since a client that's been offline that long will
* always need a full REST resync anyway.
*
* @since 0.9.0
*/
function desktop_mode_files_prune_tombstones() {
global $wpdb;
$tables = desktop_mode_files_table_names();
$cutoff = desktop_mode_files_now_ms() - ( 7 * DAY_IN_SECONDS * 1000 );
$wpdb->query( $wpdb->prepare( "DELETE FROM {$tables['tombstones']} WHERE removed_at_ms < %d", $cutoff ) );
}
add_action( 'desktop_mode_files_daily_prune', 'desktop_mode_files_prune_tombstones' );
/**
* Schedule the daily prune on activation.
*
* @since 0.9.0
*/
function desktop_mode_files_schedule_prune() {
if ( ! wp_next_scheduled( 'desktop_mode_files_daily_prune' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'desktop_mode_files_daily_prune' );
}
}
add_action( 'init', 'desktop_mode_files_schedule_prune' );
/**
* Walk the folder-parentage chain upward from `$target_parent_id` and
* return `true` when `$moving_folder_id` appears anywhere in it —
* meaning a move that sets `moving_folder.parent_id = target_parent`
* would produce an unreachable cycle (folder placed inside itself or
* inside one of its own descendants).
*
* Folder-parentage is determined by the parent_id of the folder's
* placement row, not by anything on the `folders` table. We look up
* one live placement per cursor (`LIMIT 1`) — folders with multiple
* placements (rare; shared semantics) are still safely covered
* because any one upward chain hitting the moving folder is enough
* to flag the cycle.
*
* Defends against pre-existing cycles in the data: if we re-visit a
* cursor we've already seen, we treat it as a cycle and reject, so a
* corrupted history can't drive this function into an infinite loop.
*
* @since 0.20.0
*
* @param int $user_id Acting user.
* @param int $moving_folder_id Folder being moved (its `folders.id`).
* @param int $target_parent_id New container folder id (0 = desktop root).
* @return bool True when the move would create a cycle.
*/
function desktop_mode_files_would_create_folder_cycle( $user_id, $moving_folder_id, $target_parent_id ) {
$moving_folder_id = (int) $moving_folder_id;
$target_parent_id = (int) $target_parent_id;
$user_id = (int) $user_id;
if ( $moving_folder_id <= 0 || $target_parent_id <= 0 || $user_id <= 0 ) {
return false;
}
if ( $moving_folder_id === $target_parent_id ) {
return true;
}
global $wpdb;
$tables = desktop_mode_files_table_names();
$visited = array();
$cursor = $target_parent_id;
// Hard cap to defend against catastrophically deep trees too —
// real installs won't approach 256.
$max_depth = 256;
while ( $cursor > 0 && $max_depth-- > 0 ) {
if ( $cursor === $moving_folder_id ) {
return true;
}
if ( isset( $visited[ $cursor ] ) ) {
// Pre-existing cycle in the data — bail safe by treating
// the move as cycle-creating too. Better to refuse a
// suspicious move than to deepen the damage.
return true;
}
$visited[ $cursor ] = true;
// `LIMIT 1` is enough — any upward chain that reaches the
// moving folder flags the cycle. Trashed rows excluded so a
// recycled-then-recovered ancestor doesn't poison the check.
$parent_of_cursor = $wpdb->get_var(
$wpdb->prepare(
"SELECT parent_id FROM {$tables['placements']}
WHERE owner_id = %d
AND file_type = 'folder'
AND file_ref = %s
AND trashed_at_ms IS NULL
LIMIT 1",
$user_id,
(string) $cursor
)
);
if ( null === $parent_of_cursor ) {
// Folder has no live placement under this user — chain
// ends here. No cycle.
return false;
}
$cursor = (int) $parent_of_cursor;
}
return false;
}