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

627 lines 21.6 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 'restRoot' => esc_url_raw( rest_url() ),
276 'restNonce' => wp_create_nonce( 'wp_rest' ),
277 'postsUrl' => esc_url_raw( rest_url( 'wp/v2/users' ) ),
278 'editPostUrlBase' => esc_url_raw( admin_url( 'user-edit.php' ) ),
279 'newPostUrl' => esc_url_raw( admin_url( 'user-new.php' ) ),
280 'usersUrl' => esc_url_raw( rest_url( 'wp/v2/users' ) ),
281 'currentUserId' => $viewer_id,
282 'defaultPerPage' => 20,
283 'queryArgs' => openstation_users_window_default_query_args(),
284
285 // Capability flags surfaced to the JS — UI hides actions
286 // the viewer can't perform. Server still re-checks every
287 // mutation, so a tampered flag here changes nothing
288 // security-wise.
289 'canEdit' => current_user_can( 'edit_users' ),
290 'canPromote' => current_user_can( 'promote_users' ),
291 'canCreate' => current_user_can( 'create_users' ),
292 'canDelete' => is_multisite()
293 ? current_user_can( 'remove_users' )
294 : current_user_can( 'delete_users' ),
295 'isMultisite' => is_multisite(),
296
297 // Role list — `{ slug: name }` for every role the viewer
298 // can assign. Empty when the viewer lacks `promote_users`.
299 'assignableRoles' => openstation_users_window_role_label_map( $viewer_id ),
300 // Full role catalog for the role-FILTER dropdown (which
301 // shows EVERY role on the site, even those the viewer
302 // can't assign — they can still filter by them).
303 'allRoles' => openstation_users_window_all_roles_map(),
304
305 // Available site locales for the Add User form's
306 // language dropdown. `'site-default'` = empty string
307 // (the user inherits the site's locale).
308 'locales' => openstation_users_window_locales_map(),
309 'siteLocale' => (string) get_locale(),
310 'defaultRole' => (string) get_option( 'default_role', 'subscriber' ),
311 'createUserUrl' => esc_url_raw(
312 rest_url( 'desktop-mode/v1/users' )
313 ),
314
315 // REST mutation routes — the JS bundle reads these so a
316 // rename or namespace move stays in one place.
317 'bulkRoleUrl' => esc_url_raw(
318 rest_url( 'desktop-mode/v1/users/bulk-role' )
319 ),
320 'bulkDeleteUrl' => esc_url_raw(
321 rest_url( 'desktop-mode/v1/users/bulk-delete' )
322 ),
323 // Profile sub-tab — uses the same config blob to read
324 // the user-edit field option lists, the insights
325 // endpoint base, and the locale/role maps.
326 'insightsUrlBase' => esc_url_raw(
327 rest_url( 'desktop-mode/v1/users/' )
328 ),
329 /** This filter is documented in wp-includes/user.php */
330 // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's filter; the window must offer the same contact fields profile.php does.
331 'contactMethods' => (array) apply_filters(
332 'user_contactmethods',
333 array(),
334 null
335 ),
336 // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
337 'colorSchemes' => function_exists( 'openstation_user_edit_window_color_schemes' )
338 ? openstation_user_edit_window_color_schemes()
339 : array(),
340 'sendResetUrlBase' => esc_url_raw(
341 rest_url( 'desktop-mode/v1/users/' )
342 ),
343 ),
344 );
345
346 /**
347 * Filter the args used to register the native Users window.
348 *
349 * @param array $window_args Args passed to `openstation_register_window()`.
350 */
351 $window_args = (array) apply_filters( 'openstation_users_window_args', $window_args );
352
353 $registered = openstation_register_window( 'desktop-mode-users', $window_args );
354 if ( is_wp_error( $registered ) ) {
355 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
356 error_log( '[openstation] Native Users window registration failed: ' . $registered->get_error_message() );
357 }
358 }
359 add_action( 'init', 'openstation_users_window_register_window', 20 );
360
361 /**
362 * Default REST query args for the Users window.
363 *
364 * @return array
365 */
366 function openstation_users_window_default_query_args() {
367 $args = array(
368 // `_fields` whitelists the columns we render plus the four
369 // REST fields registered below. Skipping the whitelist would
370 // pull every meta + every embedded link the user controller
371 // emits — heavy on every page change.
372 '_fields' =>
373 'id,name,slug,email,url,description,roles,registered_date,avatar_urls,'
374 . 'openstation_user_stats,openstation_last_login,openstation_presence,'
375 . 'openstation_can_edit,openstation_assignable_roles',
376 // `who=authors` would hide subscribers — we want the full
377 // list. `context=edit` is required because `email`, `roles`,
378 // and `registered_date` are edit-context-only on
379 // `/wp/v2/users`; in `view` they're omitted from the response
380 // entirely (independent of `_fields`), which paints the
381 // table as "No role" / empty email / empty registered date.
382 // The window is already gated on `list_users`, the cap
383 // `context=edit` requires, so this is safe.
384 'context' => 'edit',
385 'per_page' => 20,
386 );
387
388 /**
389 * Filter the default outbound REST query args for the Users window.
390 *
391 * @param array $args Default args.
392 */
393 return (array) apply_filters( 'openstation_users_window_query_args', $args );
394 }
395
396 /**
397 * Build the `{ slug: label }` map for every role on the install.
398 *
399 * Used by the Users window's role FILTER (vs. role-CHANGE menu —
400 * see {@see openstation_users_window_role_label_map()} for that).
401 *
402 * @return array<string,string>
403 */
404 function openstation_users_window_all_roles_map() {
405 $roles = wp_roles();
406 $map = array();
407 foreach ( (array) $roles->roles as $slug => $info ) {
408 $map[ (string) $slug ] = isset( $info['name'] )
409 ? translate_user_role( (string) $info['name'] )
410 : (string) $slug;
411 }
412 return $map;
413 }
414
415 /**
416 * Build the `{ slug: label }` map for roles the viewer is allowed
417 * to assign. Empty when the viewer lacks `promote_users`.
418 *
419 * @param int $viewer_id Viewer's user id.
420 * @return array<string,string>
421 */
422 function openstation_users_window_role_label_map( $viewer_id ) {
423 $slugs = openstation_users_window_assignable_roles( (int) $viewer_id );
424 if ( empty( $slugs ) ) {
425 return array();
426 }
427 $all = openstation_users_window_all_roles_map();
428 $out = array();
429 foreach ( $slugs as $slug ) {
430 if ( isset( $all[ $slug ] ) ) {
431 $out[ $slug ] = $all[ $slug ];
432 }
433 }
434 return $out;
435 }
436
437 /**
438 * Register the Users-window REST fields on the `user` resource.
439 *
440 * Fields:
441 *
442 * - openstation_user_stats — `{ posts: int, pages: int, comments: int }`
443 * - openstation_last_login — UTC unix timestamp, or null when never
444 * - openstation_presence — 'online' | 'inactive' | 'offline'
445 * - openstation_can_edit — viewer can edit / promote this row
446 * - openstation_assignable_roles — role slugs the viewer can assign to this row
447 *
448 * Each field returns sensible empty defaults when the viewer lacks
449 * the cap to see the value, so the JS never has to defend against
450 * "field present but null". The fields register on every REST request
451 * (the `user` resource is partially public — published authors are
452 * visible to anyone), so `openstation_last_login` and
453 * `openstation_presence` gate on `list_users` (or self) inside their
454 * callbacks; `openstation_user_stats` stays open because it only
455 * counts published content.
456 */
457 function openstation_users_window_register_rest_fields() {
458 register_rest_field(
459 'user',
460 'openstation_user_stats',
461 array(
462 'get_callback' => static function ( $row ) {
463 $id = isset( $row['id'] ) ? (int) $row['id'] : 0;
464 if ( $id <= 0 ) {
465 return array(
466 'posts' => 0,
467 'pages' => 0,
468 'comments' => 0,
469 );
470 }
471 $posts = (int) count_user_posts( $id, 'post', true );
472 $pages = post_type_exists( 'page' )
473 ? (int) count_user_posts( $id, 'page', true )
474 : 0;
475 $comments = (int) get_comments(
476 array(
477 'user_id' => $id,
478 'count' => true,
479 'status' => 'approve',
480 )
481 );
482 return array(
483 'posts' => $posts,
484 'pages' => $pages,
485 'comments' => $comments,
486 );
487 },
488 'schema' => array(
489 'description' => __( 'Per-user content stats: published post / page / comment counts.', 'desktop-mode' ),
490 'type' => 'object',
491 'context' => array( 'view', 'edit', 'embed' ),
492 'readonly' => true,
493 ),
494 )
495 );
496
497 register_rest_field(
498 'user',
499 'openstation_last_login',
500 array(
501 'get_callback' => static function ( $row ) {
502 $id = isset( $row['id'] ) ? (int) $row['id'] : 0;
503 if ( $id <= 0 ) {
504 return null;
505 }
506 // Last-login time is sensitive. Only viewers who can see
507 // the Users list — or the user themselves — get the
508 // real value.
509 if ( get_current_user_id() !== $id && ! current_user_can( 'list_users' ) ) {
510 return null;
511 }
512 $ts = (int) get_user_meta( $id, OPENSTATION_LAST_LOGIN_META_KEY, true );
513 return $ts > 0 ? $ts : null;
514 },
515 'schema' => array(
516 'description' => __( 'UTC unix timestamp of this user’s last successful login, or null when never recorded.', 'desktop-mode' ),
517 'type' => array( 'integer', 'null' ),
518 'context' => array( 'view', 'edit', 'embed' ),
519 'readonly' => true,
520 ),
521 )
522 );
523
524 register_rest_field(
525 'user',
526 'openstation_presence',
527 array(
528 'get_callback' => static function ( $row ) {
529 $id = isset( $row['id'] ) ? (int) $row['id'] : 0;
530 if ( $id <= 0 || ! function_exists( 'openstation_presence_status_for_user' ) ) {
531 return 'offline';
532 }
533 // Live presence is sensitive. Only viewers who can see
534 // the Users list — or the user themselves — get the
535 // real value.
536 if ( get_current_user_id() !== $id && ! current_user_can( 'list_users' ) ) {
537 return 'offline';
538 }
539 return (string) openstation_presence_status_for_user( $id );
540 },
541 'schema' => array(
542 'description' => __( 'Live presence status: online / inactive / offline.', 'desktop-mode' ),
543 'type' => 'string',
544 'enum' => array( 'online', 'inactive', 'offline' ),
545 'context' => array( 'view', 'edit', 'embed' ),
546 'readonly' => true,
547 ),
548 )
549 );
550
551 register_rest_field(
552 'user',
553 'openstation_can_edit',
554 array(
555 'get_callback' => static function ( $row ) {
556 $id = isset( $row['id'] ) ? (int) $row['id'] : 0;
557 $viewer = (int) get_current_user_id();
558 if ( $id <= 0 || $viewer <= 0 ) {
559 return false;
560 }
561 return (bool) user_can( $viewer, 'edit_user', $id );
562 },
563 'schema' => array(
564 'description' => __( 'Whether the requester can edit this user.', 'desktop-mode' ),
565 'type' => 'boolean',
566 'context' => array( 'view', 'edit', 'embed' ),
567 'readonly' => true,
568 ),
569 )
570 );
571
572 register_rest_field(
573 'user',
574 'openstation_assignable_roles',
575 array(
576 'get_callback' => static function ( $row ) {
577 $id = isset( $row['id'] ) ? (int) $row['id'] : 0;
578 $viewer = (int) get_current_user_id();
579 if ( $id <= 0 || $viewer <= 0 ) {
580 return array();
581 }
582 return array_values( openstation_users_window_assignable_roles( $viewer, $id ) );
583 },
584 'schema' => array(
585 'description' => __( 'Role slugs the requester can assign to this user.', 'desktop-mode' ),
586 'type' => 'array',
587 'items' => array( 'type' => 'string' ),
588 'context' => array( 'view', 'edit', 'embed' ),
589 'readonly' => true,
590 ),
591 )
592 );
593 }
594 add_action( 'rest_api_init', 'openstation_users_window_register_rest_fields' );
595
596 /**
597 * Build the `[ slug => label ]` map for the Add User locale picker.
598 *
599 * Site default is keyed under `''` (empty string) so the form can
600 * reflect "Site default — English (United States)" as the default
601 * choice without forcing the user to know which slug to send.
602 *
603 * @return array<string,string>
604 */
605 function openstation_users_window_locales_map() {
606 $out = array(
607 '' => sprintf(
608 // translators: %s is the site's current locale (e.g. "en_US").
609 __( 'Site default — %s', 'desktop-mode' ),
610 get_locale()
611 ),
612 );
613 if ( ! function_exists( 'get_available_languages' ) ) {
614 require_once ABSPATH . 'wp-admin/includes/translation-install.php';
615 }
616 $languages = (array) get_available_languages();
617 foreach ( $languages as $slug ) {
618 $out[ (string) $slug ] = (string) $slug;
619 }
620 // Always offer en_US even if no .mo file is installed — core
621 // always treats it as available.
622 if ( ! isset( $out['en_US'] ) ) {
623 $out['en_US'] = 'en_US';
624 }
625 return $out;
626 }
627