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

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