PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.3
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.3
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.3, at includes/desktop-files/folders-store.php

687 lines 21.9 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 $share_rows = (array) $wpdb->get_results(
473 $wpdb->prepare(
474 "SELECT * FROM {$tables['shares']} WHERE folder_id = %d",
475 $folder_id
476 ),
477 ARRAY_A
478 );
479 $share_ids = array();
480 foreach ( $share_rows as $share_row ) {
481 $share_ids[] = (int) $share_row['id'];
482 }
483 if ( ! empty( $share_ids ) ) {
484 $placeholders = implode( ',', array_fill( 0, count( $share_ids ), '%d' ) );
485 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
486 $wpdb->query(
487 $wpdb->prepare(
488 "DELETE FROM {$tables['decisions']} WHERE share_id IN ($placeholders)",
489 $share_ids
490 )
491 );
492 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
493 $wpdb->query(
494 $wpdb->prepare(
495 "DELETE FROM {$tables['shares']} WHERE id IN ($placeholders)",
496 $share_ids
497 )
498 );
499 // Fire the same share-revoked action each individual revoke
500 // would have fired, so plugins listening for that signal
501 // don't have to also subscribe to the cascade-specific
502 // hook. `$row` carries the pre-delete share data so
503 // listeners can read principal / capability for audit.
504 foreach ( $share_rows as $share_row ) {
505 /** @see desktop_mode_folder_share_revoke */
506 do_action(
507 'desktop_mode_files_share_revoked',
508 (int) $share_row['id'],
509 $share_row,
510 $user_id
511 );
512 }
513 $summary['shares_revoked'] = array_merge(
514 $summary['shares_revoked'],
515 $share_ids
516 );
517 }
518
519 // 3) Placements POINTING AT this folder (every recipient's
520 // accept-created root placement, plus the owner's own).
521 $pointing_ids = (array) $wpdb->get_col(
522 $wpdb->prepare(
523 "SELECT id FROM {$tables['placements']}
524 WHERE file_type = 'folder' AND file_ref = %s",
525 (string) $folder_id
526 )
527 );
528 foreach ( $pointing_ids as $pid ) {
529 desktop_mode_files_write_tombstone( 'placement', (int) $pid );
530 }
531 if ( ! empty( $pointing_ids ) ) {
532 $placeholders = implode( ',', array_fill( 0, count( $pointing_ids ), '%d' ) );
533 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
534 $wpdb->query(
535 $wpdb->prepare(
536 "DELETE FROM {$tables['placements']} WHERE id IN ($placeholders)",
537 $pointing_ids
538 )
539 );
540 $summary['placements_pointing'] = array_merge(
541 $summary['placements_pointing'],
542 array_map( 'intval', $pointing_ids )
543 );
544 }
545
546 // 4) Placements INSIDE this folder. After step 1 the sub-folder
547 // placements have been recursively handled for owner-owned
548 // sub-folders; whatever remains here (loose post / link /
549 // user / etc. placements, plus orphan folder placements
550 // whose folder we did NOT recurse into because someone else
551 // owns it) gets deleted with a tombstone each.
552 $inside_ids = (array) $wpdb->get_col(
553 $wpdb->prepare(
554 "SELECT id FROM {$tables['placements']} WHERE parent_id = %d",
555 $folder_id
556 )
557 );
558 foreach ( $inside_ids as $cid ) {
559 desktop_mode_files_write_tombstone( 'placement', (int) $cid );
560 }
561 if ( ! empty( $inside_ids ) ) {
562 $wpdb->delete( $tables['placements'], array( 'parent_id' => $folder_id ), array( '%d' ) );
563 $summary['placements_inside'] = array_merge(
564 $summary['placements_inside'],
565 array_map( 'intval', $inside_ids )
566 );
567 }
568
569 // 5) The folder row itself + its tombstone.
570 $ok = $wpdb->delete( $tables['folders'], array( 'id' => $folder_id ), array( '%d' ) );
571 if ( false === $ok ) {
572 return new WP_Error( 'desktop_mode_files_delete_failed', __( 'Failed to delete folder.', 'desktop-mode' ), array( 'status' => 500 ) );
573 }
574 desktop_mode_files_write_tombstone( 'folder', $folder_id );
575 $summary['folders_deleted'][] = $folder_id;
576
577 /**
578 * Fires after a folder is deleted. Plugins listening for share
579 * lifecycle can subscribe alongside
580 * `desktop_mode_files_share_revoked` if they want to react to
581 * cascade-revokes triggered by folder deletion.
582 *
583 * @since 0.9.0
584 *
585 * @param int $id Folder id.
586 * @param array $row Removed row.
587 */
588 do_action( 'desktop_mode_folder_deleted', $folder_id, $row );
589
590 return true;
591 }
592
593 /**
594 * Lookup a folder row by id.
595 *
596 * @since 0.9.0
597 *
598 * @param int $folder_id Folder id.
599 * @param bool $include_trashed Optional. Return the row even when
600 * soft-trashed (recycle-bin callers).
601 * Default false — trashed folders
602 * resolve to null.
603 * @return array|null
604 */
605 function desktop_mode_files_get_folder( $folder_id, $include_trashed = false ) {
606 global $wpdb;
607 $tables = desktop_mode_files_table_names();
608 $row = $wpdb->get_row(
609 $wpdb->prepare( "SELECT * FROM {$tables['folders']} WHERE id = %d", (int) $folder_id ),
610 ARRAY_A
611 );
612 if ( ! $row ) {
613 return null;
614 }
615 // Trashed folders are invisible to active code paths by
616 // default — recycle-bin callers pass `true` to opt in.
617 if ( ! $include_trashed && ! empty( $row['trashed_at_ms'] ) ) {
618 return null;
619 }
620 return desktop_mode_files_normalize_folder_row( $row );
621 }
622
623 /**
624 * Folders visible to `$user_id`. Returns the folders the viewer
625 * owns; sharing.php merges shared folders in via the
626 * `desktop_mode_files_visible_folders` filter.
627 *
628 * @since 0.9.0
629 *
630 * @param int $user_id Viewer.
631 * @return array[]
632 */
633 function desktop_mode_files_get_visible_folders( $user_id ) {
634 global $wpdb;
635 $user_id = (int) $user_id;
636 if ( $user_id <= 0 ) {
637 return array();
638 }
639
640 $tables = desktop_mode_files_table_names();
641 $rows = $wpdb->get_results(
642 $wpdb->prepare(
643 "SELECT * FROM {$tables['folders']}
644 WHERE owner_id = %d AND trashed_at_ms IS NULL",
645 $user_id
646 ),
647 ARRAY_A
648 );
649 $out = array();
650 foreach ( (array) $rows as $row ) {
651 $out[] = desktop_mode_files_normalize_folder_row( $row );
652 }
653
654 /**
655 * Filter the folders visible to a viewer. sharing.php's
656 * `desktop_mode_files_compute_visible_folders` (priority 5)
657 * merges accepted shares and `share_mode='all'` folders onto
658 * this list.
659 *
660 * @since 0.9.0
661 *
662 * @param array[] $folders Folders the viewer owns.
663 * @param int $user_id Viewer.
664 */
665 return (array) apply_filters( 'desktop_mode_files_visible_folders', $out, $user_id );
666 }
667
668 /**
669 * @since 0.9.0
670 * @internal
671 *
672 * @param array $row Raw wpdb row.
673 * @return array
674 */
675 function desktop_mode_files_normalize_folder_row( $row ) {
676 $meta_raw = isset( $row['share_meta'] ) ? (string) $row['share_meta'] : '';
677 $meta = '' !== $meta_raw ? json_decode( $meta_raw, true ) : null;
678 return array(
679 'id' => (int) $row['id'],
680 'owner_id' => (int) $row['owner_id'],
681 'name' => (string) $row['name'],
682 'share_mode' => (string) $row['share_mode'],
683 'share_meta' => is_array( $meta ) ? $meta : null,
684 'updated_at_ms' => (int) $row['updated_at_ms'],
685 );
686 }
687