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

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