PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.0.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.0.1
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 / users-window / window.php

window.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.0.1, at includes/users-window/window.php

630 lines 21.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Native Users Window: registration, template, REST fields.
4 *
5 * Mirrors the structure of the Posts/Pages windows
6 * (`includes/posts-window/`, `includes/pages-window/`) adapted for
7 * the user collection — `/wp/v2/users` rather than `/wp/v2/posts`,
8 * a Role/Email column set instead of taxonomies, and a much heavier
9 * permission story (see `permissions.php` + `rest.php`).
10 *
11 * @package OpenStation
12 */
13
14 defined( 'ABSPATH' ) || exit;
15
16 /**
17 * Echoes the native Users window's template body.
18 *
19 * Same toolbar/table/pager structure as the Posts and Pages
20 * windows. Reuses the `data-os-posts-*` selectors so the
21 * shared JS bundle binds to the same hooks regardless of window
22 * mode (the namespace is an internal contract — both windows use
23 * the same template machinery).
24 */
25 function openstation_users_window_render_template() {
26 $can_create = current_user_can( 'create_users' );
27 ob_start();
28 ?>
29 <div class="desktop-mode-posts desktop-mode-users" data-os-posts-root>
30 <os-tabs value="all" class="os-users__tabs" data-os-users-tabs>
31 <os-tab value="all"><?php esc_html_e( 'All users', 'desktop-mode' ); ?></os-tab>
32 <?php if ( $can_create ) : ?>
33 <os-tab value="add-new"><?php esc_html_e( 'Add new', 'desktop-mode' ); ?></os-tab>
34 <?php endif; ?>
35 <?php
36 /*
37 * Profile tab — always visible, always shows the
38 * CURRENT logged-in user. Other-user row clicks
39 * open a separate `desktop-mode-user-edit`
40 * window.
41 */
42 ?>
43 <os-tab value="edit" data-os-users-edit-tab>
44 <?php esc_html_e( 'Profile', 'desktop-mode' ); ?>
45 </os-tab>
46 </os-tabs>
47
48 <os-tabpanel for="all" class="os-posts__panel">
49 <header class="os-posts__toolbar" data-os-posts-toolbar>
50 <div class="os-posts__toolbar-left">
51 <os-segmented data-os-posts-status value=""></os-segmented>
52 <os-text-field
53 data-os-posts-search
54 placeholder="<?php esc_attr_e( 'Search name, username, email…', 'desktop-mode' ); ?>"
55 ></os-text-field>
56 </div>
57 <div class="os-posts__toolbar-right" data-os-posts-bulk hidden>
58 <span class="os-posts__count" data-os-posts-count></span>
59 <span class="os-posts__bulk-actions" data-os-posts-bulk-actions></span>
60 </div>
61 <div class="os-posts__toolbar-trailing">
62 <span class="os-posts__toolbar-extras" data-os-posts-toolbar-extras></span>
63 <os-button variant="ghost" data-os-posts-refresh title="<?php esc_attr_e( 'Refresh', 'desktop-mode' ); ?>">
64 <span class="dashicons dashicons-update" aria-hidden="true"></span>
65 </os-button>
66 <os-button variant="primary" data-os-posts-new>
67 <span class="dashicons dashicons-plus" aria-hidden="true"></span>
68 <?php esc_html_e( 'Add new', 'desktop-mode' ); ?>
69 </os-button>
70 </div>
71 </header>
72 <div class="os-posts__body" data-os-posts-body>
73 <os-table
74 data-os-posts-table
75 selectable="multi"
76 sticky-header
77 sticky-columns="1"
78 hover
79 striped
80 bordered
81 loading
82 >
83 <div slot="empty" class="os-posts__empty">
84 <span class="dashicons dashicons-admin-users" aria-hidden="true"></span>
85 <p><?php esc_html_e( 'No users found.', 'desktop-mode' ); ?></p>
86 <p class="os-posts__empty-hint">
87 <?php esc_html_e( 'Try a different search or change the role filter.', 'desktop-mode' ); ?>
88 </p>
89 </div>
90 </os-table>
91 </div>
92 <footer class="os-posts__pager" data-os-posts-pager>
93 <div class="os-posts__pager-meta">
94 <span data-os-posts-page-indicator></span>
95 </div>
96 <div class="os-posts__pager-nav">
97 <os-button variant="ghost" data-os-posts-prev disabled>
98 <span class="dashicons dashicons-arrow-left-alt2" aria-hidden="true"></span>
99 <?php esc_html_e( 'Previous', 'desktop-mode' ); ?>
100 </os-button>
101 <os-button variant="ghost" data-os-posts-next disabled>
102 <?php esc_html_e( 'Next', 'desktop-mode' ); ?>
103 <span class="dashicons dashicons-arrow-right-alt2" aria-hidden="true"></span>
104 </os-button>
105 <label class="os-posts__pager-perpage">
106 <?php esc_html_e( 'Per page', 'desktop-mode' ); ?>
107 <select data-os-posts-per-page>
108 <option value="10">10</option>
109 <option value="20" selected>20</option>
110 <option value="50">50</option>
111 <option value="100">100</option>
112 </select>
113 </label>
114 </div>
115 </footer>
116 </os-tabpanel>
117
118 <?php if ( $can_create ) : ?>
119 <os-tabpanel for="add-new" class="os-users__add-panel">
120 <os-form
121 data-os-users-add-form
122 submit-label="<?php esc_attr_e( 'Add user', 'desktop-mode' ); ?>"
123 reset-label="<?php esc_attr_e( 'Reset', 'desktop-mode' ); ?>"
124 >
125 <div slot="header">
126 <h2><?php esc_html_e( 'Add a new user', 'desktop-mode' ); ?></h2>
127 <p class="os-users__form-lede">
128 <?php esc_html_e( 'WordPress will create the account and (optionally) email the user a notification with a link to set their own password.', 'desktop-mode' ); ?>
129 </p>
130 </div>
131
132 <os-text-field
133 name="username"
134 label="<?php esc_attr_e( 'Username (required)', 'desktop-mode' ); ?>"
135 placeholder="<?php esc_attr_e( 'e.g. jane.doe', 'desktop-mode' ); ?>"
136 autocomplete="off"
137 required
138 ></os-text-field>
139 <os-text-field
140 name="email"
141 type="email"
142 label="<?php esc_attr_e( 'Email (required)', 'desktop-mode' ); ?>"
143 placeholder="<?php esc_attr_e( 'jane@example.com', 'desktop-mode' ); ?>"
144 autocomplete="off"
145 required
146 ></os-text-field>
147 <os-text-field
148 name="first_name"
149 label="<?php esc_attr_e( 'First name', 'desktop-mode' ); ?>"
150 autocomplete="off"
151 ></os-text-field>
152 <os-text-field
153 name="last_name"
154 label="<?php esc_attr_e( 'Last name', 'desktop-mode' ); ?>"
155 autocomplete="off"
156 ></os-text-field>
157 <os-text-field
158 name="url"
159 type="url"
160 label="<?php esc_attr_e( 'Website', 'desktop-mode' ); ?>"
161 placeholder="https://example.com"
162 autocomplete="off"
163 full-width
164 ></os-text-field>
165 <?php
166 /*
167 * The `role` and `locale` selects are declared here
168 * so the components upgrade with the rest of the
169 * form; the option list (which depends on the
170 * viewer's `editable_roles` map and the install's
171 * available languages) is appended JS-side in
172 * `mountAddUserForm`.
173 */
174 ?>
175 <os-select
176 name="role"
177 label="<?php esc_attr_e( 'Role', 'desktop-mode' ); ?>"
178 ></os-select>
179 <os-select
180 name="locale"
181 label="<?php esc_attr_e( 'Language', 'desktop-mode' ); ?>"
182 ></os-select>
183 <os-text-field
184 name="password"
185 type="password"
186 reveal
187 label="<?php esc_attr_e( 'Password', 'desktop-mode' ); ?>"
188 placeholder="<?php esc_attr_e( 'Auto-generated; click Generate to set one.', 'desktop-mode' ); ?>"
189 autocomplete="new-password"
190 full-width
191 ></os-text-field>
192 <div class="os-users__form-pwd-actions" full-width>
193 <os-button
194 variant="ghost"
195 type="button"
196 data-action="generate-password"
197 >
198 <span class="dashicons dashicons-randomize" aria-hidden="true"></span>
199 <?php esc_html_e( 'Generate strong password', 'desktop-mode' ); ?>
200 </os-button>
201 <p class="os-users__form-hint">
202 <?php esc_html_e( 'Leave blank to let WordPress generate one and email it to the user.', 'desktop-mode' ); ?>
203 </p>
204 </div>
205 <os-checkbox-label
206 name="send_notification"
207 label="<?php esc_attr_e( 'Send the new user an email about their account', 'desktop-mode' ); ?>"
208 checked
209 full-width
210 ></os-checkbox-label>
211 </os-form>
212 </os-tabpanel>
213 <?php endif; ?>
214
215 <os-tabpanel for="edit" class="os-users__edit-panel">
216 <?php
217 /*
218 * The Profile tab is hard-wired to the viewer's own user
219 * id — the JS render shell sets the `user-id` attribute
220 * once the window is mounted. The component does the
221 * rest (lazy-loads data, paints sidebar + form +
222 * activity). Other-user editing happens in the dedicated
223 * `desktop-mode-user-edit` window via row-click.
224 */
225 ?>
226 <os-user-profile data-os-user-profile-self></os-user-profile>
227 </os-tabpanel>
228 </div>
229 <?php
230 $html = (string) ob_get_clean();
231
232 /**
233 * Filter the native Users window's template HTML.
234 *
235 * @param string $html Default template HTML.
236 */
237 $filtered = (string) apply_filters( 'openstation_users_window_template_html', $html );
238
239 if ( function_exists( 'openstation_kses_native_window_template' ) ) {
240 echo openstation_kses_native_window_template( $filtered ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper kses-escapes.
241 } else {
242 // Backwards-compatible fallback for the rare case the helper
243 // isn't loaded yet (e.g. a plugin echoing the template
244 // outside the framework's lifecycle).
245 echo wp_kses( $filtered, wp_kses_allowed_html( 'post' ) );
246 }
247 }
248
249 /**
250 * Register the native Users window on `init` (priority 20).
251 */
252 function openstation_users_window_register_window() {
253 if ( ! openstation_users_window_user_can_register() ) {
254 return;
255 }
256
257 $viewer_id = (int) get_current_user_id();
258
259 $window_args = array(
260 'title' => __( 'Users', 'desktop-mode' ),
261 'icon' => 'dashicons-admin-users',
262 'template' => 'openstation_users_window_render_template',
263 // Reuse the Posts bundle — same script + style handles. The
264 // shared module branches on `cfg.mode` to render the Users
265 // view.
266 'script' => 'os-posts-window',
267 'style' => 'os-posts-window',
268 'width' => 1100,
269 'height' => 720,
270 'min_width' => 720,
271 'min_height' => 480,
272 'placement' => 'none',
273 'config' => array(
274 'mode' => 'users',
275 'introSlug' => 'users',
276 'restRoot' => esc_url_raw( rest_url() ),
277 'restNonce' => wp_create_nonce( 'wp_rest' ),
278 'postsUrl' => esc_url_raw( rest_url( 'wp/v2/users' ) ),
279 'editPostUrlBase' => esc_url_raw( admin_url( 'user-edit.php' ) ),
280 'newPostUrl' => esc_url_raw( admin_url( 'user-new.php' ) ),
281 'usersUrl' => esc_url_raw( rest_url( 'wp/v2/users' ) ),
282 'currentUserId' => $viewer_id,
283 'defaultPerPage' => 20,
284 'queryArgs' => openstation_users_window_default_query_args(),
285 'introSeen' => openstation_has_seen_intro( $viewer_id, 'users' ),
286 'introUrl' => esc_url_raw( rest_url( 'desktop-mode/v1/intros/seen' ) ),
287
288 // Capability flags surfaced to the JS — UI hides actions
289 // the viewer can't perform. Server still re-checks every
290 // mutation, so a tampered flag here changes nothing
291 // security-wise.
292 'canEdit' => current_user_can( 'edit_users' ),
293 'canPromote' => current_user_can( 'promote_users' ),
294 'canCreate' => current_user_can( 'create_users' ),
295 'canDelete' => is_multisite()
296 ? current_user_can( 'remove_users' )
297 : current_user_can( 'delete_users' ),
298 'isMultisite' => is_multisite(),
299
300 // Role list — `{ slug: name }` for every role the viewer
301 // can assign. Empty when the viewer lacks `promote_users`.
302 'assignableRoles' => openstation_users_window_role_label_map( $viewer_id ),
303 // Full role catalog for the role-FILTER dropdown (which
304 // shows EVERY role on the site, even those the viewer
305 // can't assign — they can still filter by them).
306 'allRoles' => openstation_users_window_all_roles_map(),
307
308 // Available site locales for the Add User form's
309 // language dropdown. `'site-default'` = empty string
310 // (the user inherits the site's locale).
311 'locales' => openstation_users_window_locales_map(),
312 'siteLocale' => (string) get_locale(),
313 'defaultRole' => (string) get_option( 'default_role', 'subscriber' ),
314 'createUserUrl' => esc_url_raw(
315 rest_url( 'desktop-mode/v1/users' )
316 ),
317
318 // REST mutation routes — the JS bundle reads these so a
319 // rename or namespace move stays in one place.
320 'bulkRoleUrl' => esc_url_raw(
321 rest_url( 'desktop-mode/v1/users/bulk-role' )
322 ),
323 'bulkDeleteUrl' => esc_url_raw(
324 rest_url( 'desktop-mode/v1/users/bulk-delete' )
325 ),
326 // Profile sub-tab — uses the same config blob to read
327 // the user-edit field option lists, the insights
328 // endpoint base, and the locale/role maps.
329 'insightsUrlBase' => esc_url_raw(
330 rest_url( 'desktop-mode/v1/users/' )
331 ),
332 /** This filter is documented in wp-includes/user.php */
333 // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's filter; the window must offer the same contact fields profile.php does.
334 'contactMethods' => (array) apply_filters(
335 'user_contactmethods',
336 array(),
337 null
338 ),
339 // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
340 'colorSchemes' => function_exists( 'openstation_user_edit_window_color_schemes' )
341 ? openstation_user_edit_window_color_schemes()
342 : array(),
343 'sendResetUrlBase' => esc_url_raw(
344 rest_url( 'desktop-mode/v1/users/' )
345 ),
346 ),
347 );
348
349 /**
350 * Filter the args used to register the native Users window.
351 *
352 * @param array $window_args Args passed to `openstation_register_window()`.
353 */
354 $window_args = (array) apply_filters( 'openstation_users_window_args', $window_args );
355
356 $registered = openstation_register_window( 'desktop-mode-users', $window_args );
357 if ( is_wp_error( $registered ) ) {
358 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
359 error_log( '[openstation] Native Users window registration failed: ' . $registered->get_error_message() );
360 }
361 }
362 add_action( 'init', 'openstation_users_window_register_window', 20 );
363
364 /**
365 * Default REST query args for the Users window.
366 *
367 * @return array
368 */
369 function openstation_users_window_default_query_args() {
370 $args = array(
371 // `_fields` whitelists the columns we render plus the four
372 // REST fields registered below. Skipping the whitelist would
373 // pull every meta + every embedded link the user controller
374 // emits — heavy on every page change.
375 '_fields' =>
376 'id,name,slug,email,url,description,roles,registered_date,avatar_urls,'
377 . 'openstation_user_stats,openstation_last_login,openstation_presence,'
378 . 'openstation_can_edit,openstation_assignable_roles',
379 // `who=authors` would hide subscribers — we want the full
380 // list. `context=edit` is required because `email`, `roles`,
381 // and `registered_date` are edit-context-only on
382 // `/wp/v2/users`; in `view` they're omitted from the response
383 // entirely (independent of `_fields`), which paints the
384 // table as "No role" / empty email / empty registered date.
385 // The window is already gated on `list_users`, the cap
386 // `context=edit` requires, so this is safe.
387 'context' => 'edit',
388 'per_page' => 20,
389 );
390
391 /**
392 * Filter the default outbound REST query args for the Users window.
393 *
394 * @param array $args Default args.
395 */
396 return (array) apply_filters( 'openstation_users_window_query_args', $args );
397 }
398
399 /**
400 * Build the `{ slug: label }` map for every role on the install.
401 *
402 * Used by the Users window's role FILTER (vs. role-CHANGE menu —
403 * see {@see openstation_users_window_role_label_map()} for that).
404 *
405 * @return array<string,string>
406 */
407 function openstation_users_window_all_roles_map() {
408 $roles = wp_roles();
409 $map = array();
410 foreach ( (array) $roles->roles as $slug => $info ) {
411 $map[ (string) $slug ] = isset( $info['name'] )
412 ? translate_user_role( (string) $info['name'] )
413 : (string) $slug;
414 }
415 return $map;
416 }
417
418 /**
419 * Build the `{ slug: label }` map for roles the viewer is allowed
420 * to assign. Empty when the viewer lacks `promote_users`.
421 *
422 * @param int $viewer_id Viewer's user id.
423 * @return array<string,string>
424 */
425 function openstation_users_window_role_label_map( $viewer_id ) {
426 $slugs = openstation_users_window_assignable_roles( (int) $viewer_id );
427 if ( empty( $slugs ) ) {
428 return array();
429 }
430 $all = openstation_users_window_all_roles_map();
431 $out = array();
432 foreach ( $slugs as $slug ) {
433 if ( isset( $all[ $slug ] ) ) {
434 $out[ $slug ] = $all[ $slug ];
435 }
436 }
437 return $out;
438 }
439
440 /**
441 * Register the Users-window REST fields on the `user` resource.
442 *
443 * Fields:
444 *
445 * - openstation_user_stats — `{ posts: int, pages: int, comments: int }`
446 * - openstation_last_login — UTC unix timestamp, or null when never
447 * - openstation_presence — 'online' | 'inactive' | 'offline'
448 * - openstation_can_edit — viewer can edit / promote this row
449 * - openstation_assignable_roles — role slugs the viewer can assign to this row
450 *
451 * Each field returns sensible empty defaults when the viewer lacks
452 * the cap to see the value, so the JS never has to defend against
453 * "field present but null". The fields register on every REST request
454 * (the `user` resource is partially public — published authors are
455 * visible to anyone), so `openstation_last_login` and
456 * `openstation_presence` gate on `list_users` (or self) inside their
457 * callbacks; `openstation_user_stats` stays open because it only
458 * counts published content.
459 */
460 function openstation_users_window_register_rest_fields() {
461 register_rest_field(
462 'user',
463 'openstation_user_stats',
464 array(
465 'get_callback' => static function ( $row ) {
466 $id = isset( $row['id'] ) ? (int) $row['id'] : 0;
467 if ( $id <= 0 ) {
468 return array(
469 'posts' => 0,
470 'pages' => 0,
471 'comments' => 0,
472 );
473 }
474 $posts = (int) count_user_posts( $id, 'post', true );
475 $pages = post_type_exists( 'page' )
476 ? (int) count_user_posts( $id, 'page', true )
477 : 0;
478 $comments = (int) get_comments(
479 array(
480 'user_id' => $id,
481 'count' => true,
482 'status' => 'approve',
483 )
484 );
485 return array(
486 'posts' => $posts,
487 'pages' => $pages,
488 'comments' => $comments,
489 );
490 },
491 'schema' => array(
492 'description' => __( 'Per-user content stats: published post / page / comment counts.', 'desktop-mode' ),
493 'type' => 'object',
494 'context' => array( 'view', 'edit', 'embed' ),
495 'readonly' => true,
496 ),
497 )
498 );
499
500 register_rest_field(
501 'user',
502 'openstation_last_login',
503 array(
504 'get_callback' => static function ( $row ) {
505 $id = isset( $row['id'] ) ? (int) $row['id'] : 0;
506 if ( $id <= 0 ) {
507 return null;
508 }
509 // Last-login time is sensitive. Only viewers who can see
510 // the Users list — or the user themselves — get the
511 // real value.
512 if ( get_current_user_id() !== $id && ! current_user_can( 'list_users' ) ) {
513 return null;
514 }
515 $ts = (int) get_user_meta( $id, OPENSTATION_LAST_LOGIN_META_KEY, true );
516 return $ts > 0 ? $ts : null;
517 },
518 'schema' => array(
519 'description' => __( 'UTC unix timestamp of this user’s last successful login, or null when never recorded.', 'desktop-mode' ),
520 'type' => array( 'integer', 'null' ),
521 'context' => array( 'view', 'edit', 'embed' ),
522 'readonly' => true,
523 ),
524 )
525 );
526
527 register_rest_field(
528 'user',
529 'openstation_presence',
530 array(
531 'get_callback' => static function ( $row ) {
532 $id = isset( $row['id'] ) ? (int) $row['id'] : 0;
533 if ( $id <= 0 || ! function_exists( 'openstation_presence_status_for_user' ) ) {
534 return 'offline';
535 }
536 // Live presence is sensitive. Only viewers who can see
537 // the Users list — or the user themselves — get the
538 // real value.
539 if ( get_current_user_id() !== $id && ! current_user_can( 'list_users' ) ) {
540 return 'offline';
541 }
542 return (string) openstation_presence_status_for_user( $id );
543 },
544 'schema' => array(
545 'description' => __( 'Live presence status: online / inactive / offline.', 'desktop-mode' ),
546 'type' => 'string',
547 'enum' => array( 'online', 'inactive', 'offline' ),
548 'context' => array( 'view', 'edit', 'embed' ),
549 'readonly' => true,
550 ),
551 )
552 );
553
554 register_rest_field(
555 'user',
556 'openstation_can_edit',
557 array(
558 'get_callback' => static function ( $row ) {
559 $id = isset( $row['id'] ) ? (int) $row['id'] : 0;
560 $viewer = (int) get_current_user_id();
561 if ( $id <= 0 || $viewer <= 0 ) {
562 return false;
563 }
564 return (bool) user_can( $viewer, 'edit_user', $id );
565 },
566 'schema' => array(
567 'description' => __( 'Whether the requester can edit this user.', 'desktop-mode' ),
568 'type' => 'boolean',
569 'context' => array( 'view', 'edit', 'embed' ),
570 'readonly' => true,
571 ),
572 )
573 );
574
575 register_rest_field(
576 'user',
577 'openstation_assignable_roles',
578 array(
579 'get_callback' => static function ( $row ) {
580 $id = isset( $row['id'] ) ? (int) $row['id'] : 0;
581 $viewer = (int) get_current_user_id();
582 if ( $id <= 0 || $viewer <= 0 ) {
583 return array();
584 }
585 return array_values( openstation_users_window_assignable_roles( $viewer, $id ) );
586 },
587 'schema' => array(
588 'description' => __( 'Role slugs the requester can assign to this user.', 'desktop-mode' ),
589 'type' => 'array',
590 'items' => array( 'type' => 'string' ),
591 'context' => array( 'view', 'edit', 'embed' ),
592 'readonly' => true,
593 ),
594 )
595 );
596 }
597 add_action( 'rest_api_init', 'openstation_users_window_register_rest_fields' );
598
599 /**
600 * Build the `[ slug => label ]` map for the Add User locale picker.
601 *
602 * Site default is keyed under `''` (empty string) so the form can
603 * reflect "Site default — English (United States)" as the default
604 * choice without forcing the user to know which slug to send.
605 *
606 * @return array<string,string>
607 */
608 function openstation_users_window_locales_map() {
609 $out = array(
610 '' => sprintf(
611 // translators: %s is the site's current locale (e.g. "en_US").
612 __( 'Site default — %s', 'desktop-mode' ),
613 get_locale()
614 ),
615 );
616 if ( ! function_exists( 'get_available_languages' ) ) {
617 require_once ABSPATH . 'wp-admin/includes/translation-install.php';
618 }
619 $languages = (array) get_available_languages();
620 foreach ( $languages as $slug ) {
621 $out[ (string) $slug ] = (string) $slug;
622 }
623 // Always offer en_US even if no .mo file is installed — core
624 // always treats it as available.
625 if ( ! isset( $out['en_US'] ) ) {
626 $out['en_US'] = 'en_US';
627 }
628 return $out;
629 }
630