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

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