PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.7
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 / folders-store.php

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

708 lines 22.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Folders store.
4 *
5 * CRUD primitives for the `_desktop_mode_folders` table. Folders
6 * are first-class files: they have an owner, a name, a share
7 * mode, and a JSON `share_meta` column carrying the user / role
8 * lists when `share_mode` is `users` or `roles`.
9 *
10 * Visibility beyond the owner is computed by sharing.php, which
11 * hooks the `desktop_mode_files_visible_folders` filter at
12 * priority 5 to merge accepted shares and `share_mode='all'`
13 * folders onto the owner's list.
14 *
15 * @package WPDesktopMode
16 * @since 0.9.0
17 */
18
19 defined( 'ABSPATH' ) || exit;
20
21 /** Allowed share-mode values. */
22 function desktop_mode_files_share_modes() {
23 $modes = array( 'private', 'users', 'roles', 'all' );
24 /**
25 * Filter the allowed `share_mode` values. Plugins can add
26 * (e.g. 'team') by registering both the value here and a
27 * matching visibility callback.
28 *
29 * @since 0.9.0
30 *
31 * @param string[] $modes Default modes.
32 */
33 return (array) apply_filters( 'desktop_mode_files_share_modes', $modes );
34 }
35
36 /**
37 * Create a folder.
38 *
39 * @since 0.9.0
40 *
41 * @param int $owner_id Owner.
42 * @param array $args `name`, `share_mode`, `share_meta`.
43 * @return int|WP_Error Folder id on success.
44 */
45 function desktop_mode_files_create_folder( $owner_id, $args = array() ) {
46 global $wpdb;
47
48 $owner_id = (int) $owner_id;
49 if ( $owner_id <= 0 ) {
50 return new WP_Error( 'desktop_mode_files_invalid_user', __( 'A user id is required.', 'desktop-mode' ), array( 'status' => 400 ) );
51 }
52
53 $args = wp_parse_args( $args, array(
54 'name' => '',
55 'share_mode' => 'private',
56 'share_meta' => null,
57 ) );
58
59 $name = sanitize_text_field( (string) $args['name'] );
60 if ( '' === $name ) {
61 return new WP_Error( 'desktop_mode_files_missing_name', __( 'Folder name is required.', 'desktop-mode' ), array( 'status' => 400 ) );
62 }
63
64 $mode = (string) $args['share_mode'];
65 $modes = desktop_mode_files_share_modes();
66 if ( ! in_array( $mode, $modes, true ) ) {
67 return new WP_Error( 'desktop_mode_files_invalid_share_mode', __( 'Invalid share mode.', 'desktop-mode' ), array( 'status' => 400, 'mode' => $mode ) );
68 }
69
70 $tables = desktop_mode_files_table_names();
71 $now = desktop_mode_files_now_ms();
72 $row = array(
73 'owner_id' => $owner_id,
74 'name' => $name,
75 'share_mode' => $mode,
76 'share_meta' => null === $args['share_meta'] ? null : wp_json_encode( $args['share_meta'] ),
77 'updated_at_ms' => $now,
78 );
79
80 $ok = $wpdb->insert( $tables['folders'], $row, array( '%d', '%s', '%s', '%s', '%d' ) );
81 if ( false === $ok ) {
82 return new WP_Error( 'desktop_mode_files_insert_failed', __( 'Failed to create folder.', 'desktop-mode' ), array( 'status' => 500 ) );
83 }
84 $id = (int) $wpdb->insert_id;
85
86 $row['id'] = $id;
87
88 /**
89 * Fires after a folder is created.
90 *
91 * @since 0.9.0
92 *
93 * @param int $id Folder id.
94 * @param array $row Inserted row.
95 */
96 do_action( 'desktop_mode_folder_created', $id, $row );
97
98 return $id;
99 }
100
101 /**
102 * Update a folder. Only the owner can update for now.
103 *
104 * @since 0.9.0
105 *
106 * @param int $folder_id Folder id.
107 * @param int $user_id Acting user.
108 * @param array $changes `name`, `share_mode`, `share_meta`.
109 * @return true|WP_Error
110 */
111 function desktop_mode_files_update_folder( $folder_id, $user_id, $changes = array() ) {
112 global $wpdb;
113
114 $folder_id = (int) $folder_id;
115 $user_id = (int) $user_id;
116 $prev = desktop_mode_files_get_folder( $folder_id );
117 if ( ! $prev ) {
118 return new WP_Error( 'desktop_mode_files_not_found', __( 'Folder not found.', 'desktop-mode' ), array( 'status' => 404 ) );
119 }
120 if ( (int) $prev['owner_id'] !== $user_id ) {
121 return new WP_Error( 'desktop_mode_files_forbidden', __( 'You cannot edit this folder.', 'desktop-mode' ), array( 'status' => 403 ) );
122 }
123
124 $set = array();
125 $fmt = array();
126
127 if ( isset( $changes['name'] ) ) {
128 $name = sanitize_text_field( (string) $changes['name'] );
129 if ( '' === $name ) {
130 return new WP_Error( 'desktop_mode_files_missing_name', __( 'Folder name cannot be empty.', 'desktop-mode' ), array( 'status' => 400 ) );
131 }
132 $set['name'] = $name;
133 $fmt[] = '%s';
134 }
135 if ( isset( $changes['share_mode'] ) ) {
136 $mode = (string) $changes['share_mode'];
137 $modes = desktop_mode_files_share_modes();
138 if ( ! in_array( $mode, $modes, true ) ) {
139 return new WP_Error( 'desktop_mode_files_invalid_share_mode', __( 'Invalid share mode.', 'desktop-mode' ), array( 'status' => 400 ) );
140 }
141 $set['share_mode'] = $mode;
142 $fmt[] = '%s';
143 }
144 if ( array_key_exists( 'share_meta', $changes ) ) {
145 $set['share_meta'] = null === $changes['share_meta'] ? null : wp_json_encode( $changes['share_meta'] );
146 $fmt[] = '%s';
147 }
148 if ( empty( $set ) ) {
149 return true;
150 }
151
152 $now = desktop_mode_files_now_ms();
153 $set['updated_at_ms'] = $now;
154 $fmt[] = '%d';
155
156 $tables = desktop_mode_files_table_names();
157 $ok = $wpdb->update( $tables['folders'], $set, array( 'id' => $folder_id ), $fmt, array( '%d' ) );
158 if ( false === $ok ) {
159 return new WP_Error( 'desktop_mode_files_update_failed', __( 'Failed to update folder.', 'desktop-mode' ), array( 'status' => 500 ) );
160 }
161
162 // Propagate rename to every placement that POINTS AT this folder
163 // (file_type='folder', file_ref=folder_id) by bumping their
164 // updated_at_ms so the heartbeat re-delivers them with a fresh
165 // `file.title`. Without this, the folder row's updated_at_ms
166 // bumps but the placements pointing at it don't, the heartbeat
167 // `placements` query skips them, and recipient tiles keep showing
168 // the OLD name until F5. The folder upsert alone is not enough —
169 // the tile title is captured on `placement.file.title` at shape
170 // time, and the client renders from the placement, not from the
171 // folder row.
172 if ( isset( $changes['name'] ) ) {
173 /**
174 * Filter the placement rows whose `updated_at_ms` should be
175 * bumped when a folder is renamed. Default = every placement
176 * with `file_type='folder'` AND `file_ref=$folder_id` —
177 * every viewer's copy of the folder tile.
178 *
179 * Plugins that synthesize folder-like placements with a
180 * different `file_type` (e.g. an "alias" placement) can join
181 * the propagation by returning a non-null SQL fragment via
182 * this filter. Return `null` to opt OUT entirely (rare).
183 *
184 * @since 0.8.5
185 *
186 * @param string $where Default WHERE clause body.
187 * @param int $folder_id Folder being renamed.
188 * @param int $user_id Acting user (folder owner).
189 */
190 $where = (string) apply_filters(
191 'desktop_mode_folder_rename_bump_where',
192 $wpdb->prepare(
193 "file_type = 'folder' AND file_ref = %s",
194 (string) $folder_id
195 ),
196 $folder_id,
197 $user_id
198 );
199 if ( '' !== $where ) {
200 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
201 $wpdb->query(
202 $wpdb->prepare(
203 "UPDATE {$tables['placements']} SET updated_at_ms = %d WHERE {$where}",
204 $now
205 )
206 );
207 }
208
209 /**
210 * Fires after a folder has been renamed and the pointing
211 * placements have been bumped. Subscribers can react (e.g.
212 * dispatch their own cross-window broadcasts, refresh sidebar
213 * displays of the folder name).
214 *
215 * @since 0.8.5
216 *
217 * @param int $folder_id Folder id.
218 * @param string $new_name New name (sanitized).
219 * @param string $old_name Previous name.
220 * @param int $user_id Acting user (folder owner).
221 */
222 do_action(
223 'desktop_mode_folder_renamed',
224 $folder_id,
225 (string) $set['name'],
226 (string) $prev['name'],
227 $user_id
228 );
229 }
230
231 $next = desktop_mode_files_get_folder( $folder_id );
232
233 /**
234 * Fires after a folder is updated.
235 *
236 * @since 0.9.0
237 *
238 * @param int $id Folder id.
239 * @param array $next Row after.
240 * @param array $prev Row before.
241 */
242 do_action( 'desktop_mode_folder_updated', $folder_id, $next, $prev );
243
244 if ( isset( $changes['share_mode'] ) || array_key_exists( 'share_meta', $changes ) ) {
245 /**
246 * Fires after a folder's share state changes (mode or
247 * meta). Plugins listening for sharing events can subscribe
248 * to this rather than diff `desktop_mode_folder_updated`.
249 *
250 * @since 0.9.0
251 *
252 * @param int $id Folder id.
253 * @param array $next Row after.
254 * @param array $prev Row before.
255 */
256 do_action( 'desktop_mode_folder_shared', $folder_id, $next, $prev );
257 }
258
259 return true;
260 }
261
262 /**
263 * Delete a folder. Owner-only.
264 *
265 * Cleanup cascades cover every piece of state that points at the
266 * folder so a deletion leaves no orphans:
267 *
268 * 1. Sub-folders the owner owns get recursively deleted — their
269 * own shares, placements, and nested children clean up via the
270 * same recursive call. (Sub-folders OWNED BY ANOTHER USER —
271 * e.g. a writer recipient created their own folder inside a
272 * shared folder — are left alone; only their placement inside
273 * this folder is removed.)
274 * 2. Every share row + per-user decision row for this folder is
275 * deleted, so recipients stop seeing it via the heartbeat's
276 * visible-folders set.
277 * 3. Every placement POINTING AT this folder (file_type='folder',
278 * file_ref=$folder_id) is deleted across ALL users — including
279 * recipients' root placements created by their `accept`. Each
280 * gets a tombstone so the heartbeat removes the tile from
281 * every connected client.
282 * 4. Every placement INSIDE the folder (parent_id=$folder_id) is
283 * deleted with tombstones.
284 * 5. The folder row itself is deleted with a folder tombstone.
285 *
286 * @since 0.9.0
287 *
288 * @param int $folder_id Folder id.
289 * @param int $user_id Acting user.
290 * @return true|WP_Error
291 */
292 function desktop_mode_files_delete_folder( $folder_id, $user_id ) {
293 $folder_id = (int) $folder_id;
294 $user_id = (int) $user_id;
295 $row = desktop_mode_files_get_folder( $folder_id );
296 if ( ! $row ) {
297 return new WP_Error( 'desktop_mode_files_not_found', __( 'Folder not found.', 'desktop-mode' ), array( 'status' => 404 ) );
298 }
299 if ( (int) $row['owner_id'] !== $user_id ) {
300 return new WP_Error( 'desktop_mode_files_forbidden', __( 'You cannot delete this folder.', 'desktop-mode' ), array( 'status' => 403 ) );
301 }
302
303 /**
304 * Filter whether a folder delete is allowed to proceed. Default
305 * is `true` once the ownership check above has passed. Return
306 * `false` or a `WP_Error` to abort.
307 *
308 * Practical uses:
309 * - Block delete when a folder has too many recipients (UX
310 * guard for accidental cascades).
311 * - Require a confirmation token / nonce stored in the user's
312 * session.
313 *
314 * @since 0.8.5
315 *
316 * @param bool|WP_Error $can Default `true`.
317 * @param int $folder_id Folder id about to be deleted.
318 * @param int $user_id Acting user (folder owner).
319 * @param array $row Folder row.
320 */
321 $can = apply_filters(
322 'desktop_mode_files_can_delete_folder',
323 true,
324 $folder_id,
325 $user_id,
326 $row
327 );
328 if ( is_wp_error( $can ) ) {
329 return $can;
330 }
331 if ( true !== $can ) {
332 return new WP_Error(
333 'desktop_mode_files_delete_vetoed',
334 __( 'A plugin blocked this folder from being deleted.', 'desktop-mode' ),
335 array( 'status' => 403 )
336 );
337 }
338
339 /**
340 * Fires before the cascade delete walks the folder's sub-tree.
341 * Listeners can persist a snapshot, log an audit entry, or
342 * stage a notification to recipients ("the folder you had
343 * access to is being deleted in 10 s").
344 *
345 * @since 0.8.5
346 *
347 * @param int $folder_id Folder being deleted.
348 * @param int $user_id Acting user.
349 * @param array $row Folder row.
350 */
351 do_action( 'desktop_mode_files_before_delete_folder', $folder_id, $user_id, $row );
352
353 $visited = array();
354 $summary = array(
355 'folders_deleted' => array(),
356 'shares_revoked' => array(),
357 'placements_pointing' => array(),
358 'placements_inside' => array(),
359 );
360 $result = desktop_mode_files_delete_folder_recursive( $folder_id, $user_id, $visited, $summary );
361 if ( is_wp_error( $result ) ) {
362 return $result;
363 }
364
365 /**
366 * Fires after the cascade delete completes, with a summary of
367 * every row that was removed. Useful for cross-window broadcast,
368 * recycle-bin badge updates, audit logging.
369 *
370 * `$summary`:
371 * - `folders_deleted` — folder ids removed (root + sub).
372 * - `shares_revoked` — share ids revoked.
373 * - `placements_pointing` — placement ids removed (rows with
374 * `file_type='folder'` pointing at
375 * any deleted folder, across users).
376 * - `placements_inside` — placement ids removed (contents of
377 * the deleted folders).
378 *
379 * @since 0.8.5
380 *
381 * @param int $folder_id Root folder of the cascade.
382 * @param int $user_id Acting user.
383 * @param array $summary Cascade summary (see above).
384 */
385 do_action(
386 'desktop_mode_files_after_delete_folder_cascade',
387 $folder_id,
388 $user_id,
389 $summary
390 );
391
392 return true;
393 }
394
395 /**
396 * Recursive worker for {@see desktop_mode_files_delete_folder}.
397 *
398 * Walks the folder's sub-tree (sub-folders the same owner owns),
399 * then on the way back up cleans up share rows, decisions,
400 * pointing-at placements, contained placements, and the folder
401 * row itself. Tombstones are written for every removed row so the
402 * heartbeat tells connected clients what's gone.
403 *
404 * `$visited` guards against cycles in case the placement graph is
405 * ever corrupted with one. The owner check happens at the public
406 * entry point above; this worker trusts its caller.
407 *
408 * @since 0.8.5
409 * @internal
410 *
411 * @param int $folder_id Folder id to delete.
412 * @param int $user_id Owner.
413 * @param array $visited Folder ids already processed.
414 * @param array|null $summary Optional. By-reference cascade summary
415 * accumulator (`folders_deleted`,
416 * `shares_revoked`, `placements_pointing`,
417 * `placements_inside`); initialized when
418 * null.
419 * @return true|WP_Error
420 */
421 function desktop_mode_files_delete_folder_recursive( $folder_id, $user_id, &$visited, &$summary = null ) {
422 global $wpdb;
423 $folder_id = (int) $folder_id;
424 if ( isset( $visited[ $folder_id ] ) ) {
425 return true;
426 }
427 $visited[ $folder_id ] = true;
428 $row = desktop_mode_files_get_folder( $folder_id );
429 if ( ! $row ) {
430 return true;
431 }
432
433 $tables = desktop_mode_files_table_names();
434 if ( null === $summary || ! is_array( $summary ) ) {
435 $summary = array(
436 'folders_deleted' => array(),
437 'shares_revoked' => array(),
438 'placements_pointing' => array(),
439 'placements_inside' => array(),
440 );
441 }
442
443 // 1) Recurse into sub-folders the owner owns. A sub-folder
444 // owned by SOMEONE ELSE (e.g. a writer recipient who built
445 // their own folder inside this one) is left intact —
446 // deleting the parent only severs the containment for the
447 // owner; the sub-folder's owner can still reach it through
448 // their own placements.
449 $sub_folder_refs = (array) $wpdb->get_col(
450 $wpdb->prepare(
451 "SELECT DISTINCT file_ref FROM {$tables['placements']}
452 WHERE parent_id = %d
453 AND file_type = 'folder'",
454 $folder_id
455 )
456 );
457 foreach ( $sub_folder_refs as $ref ) {
458 $sub_id = (int) $ref;
459 if ( $sub_id <= 0 || $sub_id === $folder_id ) {
460 continue;
461 }
462 $sub_row = desktop_mode_files_get_folder( $sub_id );
463 if ( $sub_row && (int) $sub_row['owner_id'] === $user_id ) {
464 desktop_mode_files_delete_folder_recursive( $sub_id, $user_id, $visited, $summary );
465 }
466 }
467
468 // 2) Revoke every share for this folder: shares table + per-
469 // user decisions table. The folder is going away, so the
470 // rows are obsolete; leaving them would let the heartbeat
471 // keep delivering a `removed` tombstone for ghost rows.
472 // `target_type` scoping is load-bearing: `folder_id` carries a
473 // STORED-FILE id on `target_type='file'` rows — without the
474 // predicate this cascade would revoke an unrelated user's file
475 // share whose id collides with the deleted folder's.
476 $share_rows = (array) $wpdb->get_results(
477 $wpdb->prepare(
478 "SELECT * FROM {$tables['shares']} WHERE target_type = 'folder' AND folder_id = %d",
479 $folder_id
480 ),
481 ARRAY_A
482 );
483 $share_ids = array();
484 foreach ( $share_rows as $share_row ) {
485 $share_ids[] = (int) $share_row['id'];
486 }
487 if ( ! empty( $share_ids ) ) {
488 $placeholders = implode( ',', array_fill( 0, count( $share_ids ), '%d' ) );
489 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
490 $wpdb->query(
491 $wpdb->prepare(
492 "DELETE FROM {$tables['decisions']} WHERE share_id IN ($placeholders)",
493 $share_ids
494 )
495 );
496 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
497 $wpdb->query(
498 $wpdb->prepare(
499 "DELETE FROM {$tables['shares']} WHERE id IN ($placeholders)",
500 $share_ids
501 )
502 );
503 // Fire the same share-revoked action each individual revoke
504 // would have fired, so plugins listening for that signal
505 // don't have to also subscribe to the cascade-specific
506 // hook. `$row` carries the pre-delete share data so
507 // listeners can read principal / capability for audit.
508 foreach ( $share_rows as $share_row ) {
509 /** @see desktop_mode_folder_share_revoke */
510 do_action(
511 'desktop_mode_files_share_revoked',
512 (int) $share_row['id'],
513 $share_row,
514 $user_id
515 );
516 }
517 $summary['shares_revoked'] = array_merge(
518 $summary['shares_revoked'],
519 $share_ids
520 );
521 }
522
523 // 3) Placements POINTING AT this folder (every recipient's
524 // accept-created root placement, plus the owner's own).
525 $pointing_ids = (array) $wpdb->get_col(
526 $wpdb->prepare(
527 "SELECT id FROM {$tables['placements']}
528 WHERE file_type = 'folder' AND file_ref = %s",
529 (string) $folder_id
530 )
531 );
532 foreach ( $pointing_ids as $pid ) {
533 desktop_mode_files_write_tombstone( 'placement', (int) $pid );
534 }
535 if ( ! empty( $pointing_ids ) ) {
536 $placeholders = implode( ',', array_fill( 0, count( $pointing_ids ), '%d' ) );
537 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
538 $wpdb->query(
539 $wpdb->prepare(
540 "DELETE FROM {$tables['placements']} WHERE id IN ($placeholders)",
541 $pointing_ids
542 )
543 );
544 $summary['placements_pointing'] = array_merge(
545 $summary['placements_pointing'],
546 array_map( 'intval', $pointing_ids )
547 );
548 }
549
550 // 4) Placements INSIDE this folder. After step 1 the sub-folder
551 // placements have been recursively handled for owner-owned
552 // sub-folders; whatever remains here (loose post / link /
553 // user / etc. placements, plus orphan folder placements
554 // whose folder we did NOT recurse into because someone else
555 // owns it) gets deleted with a tombstone each.
556 $inside_rows = (array) $wpdb->get_results(
557 $wpdb->prepare(
558 "SELECT * FROM {$tables['placements']} WHERE parent_id = %d",
559 $folder_id
560 ),
561 ARRAY_A
562 );
563 $inside_ids = array();
564 foreach ( $inside_rows as $inside_row ) {
565 $inside_ids[] = (int) $inside_row['id'];
566 desktop_mode_files_write_tombstone( 'placement', (int) $inside_row['id'] );
567 }
568 if ( ! empty( $inside_ids ) ) {
569 $wpdb->delete( $tables['placements'], array( 'parent_id' => $folder_id ), array( '%d' ) );
570 // Upload placements carry real bytes — run the stored-files
571 // deletion contract now that the rows are gone. Direct
572 // guarded call (not the public unplaced action) so cascade
573 // hook semantics for other types stay unchanged.
574 if ( function_exists( 'desktop_mode_stored_files_handle_unplaced' ) ) {
575 foreach ( $inside_rows as $inside_row ) {
576 if ( 'upload' === (string) $inside_row['file_type'] ) {
577 desktop_mode_stored_files_handle_unplaced(
578 (int) $inside_row['id'],
579 desktop_mode_files_normalize_placement_row( $inside_row )
580 );
581 }
582 }
583 }
584 $summary['placements_inside'] = array_merge(
585 $summary['placements_inside'],
586 array_map( 'intval', $inside_ids )
587 );
588 }
589
590 // 5) The folder row itself + its tombstone.
591 $ok = $wpdb->delete( $tables['folders'], array( 'id' => $folder_id ), array( '%d' ) );
592 if ( false === $ok ) {
593 return new WP_Error( 'desktop_mode_files_delete_failed', __( 'Failed to delete folder.', 'desktop-mode' ), array( 'status' => 500 ) );
594 }
595 desktop_mode_files_write_tombstone( 'folder', $folder_id );
596 $summary['folders_deleted'][] = $folder_id;
597
598 /**
599 * Fires after a folder is deleted. Plugins listening for share
600 * lifecycle can subscribe alongside
601 * `desktop_mode_files_share_revoked` if they want to react to
602 * cascade-revokes triggered by folder deletion.
603 *
604 * @since 0.9.0
605 *
606 * @param int $id Folder id.
607 * @param array $row Removed row.
608 */
609 do_action( 'desktop_mode_folder_deleted', $folder_id, $row );
610
611 return true;
612 }
613
614 /**
615 * Lookup a folder row by id.
616 *
617 * @since 0.9.0
618 *
619 * @param int $folder_id Folder id.
620 * @param bool $include_trashed Optional. Return the row even when
621 * soft-trashed (recycle-bin callers).
622 * Default false — trashed folders
623 * resolve to null.
624 * @return array|null
625 */
626 function desktop_mode_files_get_folder( $folder_id, $include_trashed = false ) {
627 global $wpdb;
628 $tables = desktop_mode_files_table_names();
629 $row = $wpdb->get_row(
630 $wpdb->prepare( "SELECT * FROM {$tables['folders']} WHERE id = %d", (int) $folder_id ),
631 ARRAY_A
632 );
633 if ( ! $row ) {
634 return null;
635 }
636 // Trashed folders are invisible to active code paths by
637 // default — recycle-bin callers pass `true` to opt in.
638 if ( ! $include_trashed && ! empty( $row['trashed_at_ms'] ) ) {
639 return null;
640 }
641 return desktop_mode_files_normalize_folder_row( $row );
642 }
643
644 /**
645 * Folders visible to `$user_id`. Returns the folders the viewer
646 * owns; sharing.php merges shared folders in via the
647 * `desktop_mode_files_visible_folders` filter.
648 *
649 * @since 0.9.0
650 *
651 * @param int $user_id Viewer.
652 * @return array[]
653 */
654 function desktop_mode_files_get_visible_folders( $user_id ) {
655 global $wpdb;
656 $user_id = (int) $user_id;
657 if ( $user_id <= 0 ) {
658 return array();
659 }
660
661 $tables = desktop_mode_files_table_names();
662 $rows = $wpdb->get_results(
663 $wpdb->prepare(
664 "SELECT * FROM {$tables['folders']}
665 WHERE owner_id = %d AND trashed_at_ms IS NULL",
666 $user_id
667 ),
668 ARRAY_A
669 );
670 $out = array();
671 foreach ( (array) $rows as $row ) {
672 $out[] = desktop_mode_files_normalize_folder_row( $row );
673 }
674
675 /**
676 * Filter the folders visible to a viewer. sharing.php's
677 * `desktop_mode_files_compute_visible_folders` (priority 5)
678 * merges accepted shares and `share_mode='all'` folders onto
679 * this list.
680 *
681 * @since 0.9.0
682 *
683 * @param array[] $folders Folders the viewer owns.
684 * @param int $user_id Viewer.
685 */
686 return (array) apply_filters( 'desktop_mode_files_visible_folders', $out, $user_id );
687 }
688
689 /**
690 * @since 0.9.0
691 * @internal
692 *
693 * @param array $row Raw wpdb row.
694 * @return array
695 */
696 function desktop_mode_files_normalize_folder_row( $row ) {
697 $meta_raw = isset( $row['share_meta'] ) ? (string) $row['share_meta'] : '';
698 $meta = '' !== $meta_raw ? json_decode( $meta_raw, true ) : null;
699 return array(
700 'id' => (int) $row['id'],
701 'owner_id' => (int) $row['owner_id'],
702 'name' => (string) $row['name'],
703 'share_mode' => (string) $row['share_mode'],
704 'share_meta' => is_array( $meta ) ? $meta : null,
705 'updated_at_ms' => (int) $row['updated_at_ms'],
706 );
707 }
708