| 1 |
<?php |
| 2 |
/** |
| 3 |
* OpenStation — Station Home cards. |
| 4 |
* |
| 5 |
* Third-party plugins register small, structured cards here instead of |
| 6 |
* injecting arbitrary markup into Station Home. Cards are user-controlled: |
| 7 |
* each declaration chooses an initial state and every user can subsequently |
| 8 |
* opt in or out without changing the plugin's site-wide configuration. |
| 9 |
* |
| 10 |
* @package OpenStation |
| 11 |
*/ |
| 12 |
|
| 13 |
defined( 'ABSPATH' ) || exit; |
| 14 |
|
| 15 |
/** Per-user map of explicit Station Home card choices (`card-id => bool`). */ |
| 16 |
const OPENSTATION_STATION_HOME_CARD_PREFERENCES_META = 'openstation_station_home_card_preferences'; |
| 17 |
|
| 18 |
/** |
| 19 |
* Register a card contributed by a plugin to Station Home. |
| 20 |
* |
| 21 |
* The callback runs only when the current user has enabled the card. It |
| 22 |
* returns structured, plain-text data so the Station Home renderer keeps |
| 23 |
* ownership of accessibility, layout, responsive behavior, and theming. |
| 24 |
* |
| 25 |
* Example: |
| 26 |
* |
| 27 |
* ```php |
| 28 |
* openstation_register_station_home_card( 'my-plugin-orders', array( |
| 29 |
* 'label' => __( 'Orders', 'my-plugin' ), |
| 30 |
* 'description' => __( 'Orders waiting to be fulfilled.', 'my-plugin' ), |
| 31 |
* 'provider' => __( 'My Plugin', 'my-plugin' ), |
| 32 |
* 'icon' => 'dashicons-cart', |
| 33 |
* 'default_enabled' => false, |
| 34 |
* 'capabilities' => array( 'manage_options' ), |
| 35 |
* 'callback' => function () { |
| 36 |
* return array( |
| 37 |
* 'value' => '4', |
| 38 |
* 'detail' => __( 'Ready to fulfil', 'my-plugin' ), |
| 39 |
* 'url' => admin_url( 'admin.php?page=my-plugin-orders' ), |
| 40 |
* 'action_label' => __( 'Open orders', 'my-plugin' ), |
| 41 |
* 'tone' => 'warning', |
| 42 |
* ); |
| 43 |
* }, |
| 44 |
* ) ); |
| 45 |
* ``` |
| 46 |
* |
| 47 |
* @param string $id Unique kebab-case card id. |
| 48 |
* @param array $args { |
| 49 |
* Card registration options. |
| 50 |
* |
| 51 |
* @type string $label Human-readable card name. Required. |
| 52 |
* @type string $description Explanation shown in the card picker. |
| 53 |
* @type string $provider Plugin/provider name shown on the card. |
| 54 |
* @type string $icon Dashicon class or safe image URL. |
| 55 |
* @type callable $callback Returns the current card data. Required. |
| 56 |
* @type bool $default_enabled Initial state until the user chooses. |
| 57 |
* @type int $order Sort order within the contributed area. |
| 58 |
* @type string[] $capabilities Gate: ALL capabilities must match. |
| 59 |
* } |
| 60 |
* @return true|WP_Error `true` on success; `WP_Error` otherwise. |
| 61 |
*/ |
| 62 |
function openstation_register_station_home_card( $id, $args = array() ) { |
| 63 |
$raw_id = (string) $id; |
| 64 |
$id = sanitize_key( $raw_id ); |
| 65 |
if ( '' === $id || $raw_id !== $id ) { |
| 66 |
return openstation_registration_error( |
| 67 |
'openstation_invalid_station_home_card_id', |
| 68 |
__( 'Station Home card id is required and must be a valid slug.', 'desktop-mode' ) |
| 69 |
); |
| 70 |
} |
| 71 |
|
| 72 |
$args = wp_parse_args( |
| 73 |
$args, |
| 74 |
array( |
| 75 |
'label' => '', |
| 76 |
'description' => '', |
| 77 |
'provider' => '', |
| 78 |
'icon' => 'dashicons-admin-plugins', |
| 79 |
'callback' => null, |
| 80 |
'default_enabled' => false, |
| 81 |
'order' => 10, |
| 82 |
'capabilities' => array(), |
| 83 |
) |
| 84 |
); |
| 85 |
|
| 86 |
foreach ( (array) $args['capabilities'] as $capability ) { |
| 87 |
if ( ! current_user_can( (string) $capability ) ) { |
| 88 |
return openstation_registration_error( |
| 89 |
'openstation_capability_denied', |
| 90 |
sprintf( |
| 91 |
/* translators: %s: capability slug. */ |
| 92 |
__( 'Current user lacks the %s capability required to register this Station Home card.', 'desktop-mode' ), |
| 93 |
(string) $capability |
| 94 |
), |
| 95 |
array( |
| 96 |
'capability' => (string) $capability, |
| 97 |
'id' => $id, |
| 98 |
) |
| 99 |
); |
| 100 |
} |
| 101 |
} |
| 102 |
|
| 103 |
$label = sanitize_text_field( (string) $args['label'] ); |
| 104 |
if ( '' === $label ) { |
| 105 |
return openstation_registration_error( |
| 106 |
'openstation_missing_label', |
| 107 |
__( 'Station Home card registration requires a non-empty `label`.', 'desktop-mode' ), |
| 108 |
array( 'id' => $id ) |
| 109 |
); |
| 110 |
} |
| 111 |
if ( ! is_callable( $args['callback'] ) ) { |
| 112 |
return openstation_registration_error( |
| 113 |
'openstation_invalid_callback', |
| 114 |
__( 'Station Home card registration requires a callable `callback`.', 'desktop-mode' ), |
| 115 |
array( 'id' => $id ) |
| 116 |
); |
| 117 |
} |
| 118 |
|
| 119 |
$entry = array( |
| 120 |
'id' => $id, |
| 121 |
'label' => $label, |
| 122 |
'description' => sanitize_textarea_field( (string) $args['description'] ), |
| 123 |
'provider' => sanitize_text_field( (string) $args['provider'] ), |
| 124 |
'icon' => openstation_sanitize_dock_icon( (string) $args['icon'] ), |
| 125 |
'callback' => $args['callback'], |
| 126 |
'default_enabled' => (bool) $args['default_enabled'], |
| 127 |
'order' => (int) $args['order'], |
| 128 |
); |
| 129 |
openstation_station_home_card_registry( $id, $entry ); |
| 130 |
|
| 131 |
/** |
| 132 |
* Fires after a Station Home card is successfully registered. |
| 133 |
* |
| 134 |
* @param string $id Card id. |
| 135 |
* @param array $entry Stored registry entry. |
| 136 |
*/ |
| 137 |
do_action( 'openstation_station_home_card_registered', $id, $entry ); |
| 138 |
|
| 139 |
return true; |
| 140 |
} |
| 141 |
|
| 142 |
/** |
| 143 |
* Internal Station Home card registry. |
| 144 |
* |
| 145 |
* @internal |
| 146 |
* |
| 147 |
* @param string $id Card id, empty for all, or `__flush__` for tests. |
| 148 |
* @param array|null $entry Entry to write. |
| 149 |
* @return array|null |
| 150 |
*/ |
| 151 |
function openstation_station_home_card_registry( $id = '', $entry = null ) { |
| 152 |
static $registry = null; |
| 153 |
if ( null === $registry ) { |
| 154 |
$registry = openstation_create_registry(); |
| 155 |
} |
| 156 |
return $registry( $id, $entry ); |
| 157 |
} |
| 158 |
|
| 159 |
/** |
| 160 |
* Remove a Station Home card registration. |
| 161 |
* |
| 162 |
* @param string $id Card id. |
| 163 |
* @return bool Whether a card was removed. |
| 164 |
*/ |
| 165 |
function openstation_unregister_station_home_card( $id ) { |
| 166 |
$id = sanitize_key( (string) $id ); |
| 167 |
if ( '' === $id || null === openstation_station_home_card_registry( $id ) ) { |
| 168 |
return false; |
| 169 |
} |
| 170 |
|
| 171 |
$cards = openstation_station_home_card_registry(); |
| 172 |
unset( $cards[ $id ] ); |
| 173 |
openstation_station_home_card_registry( '__flush__' ); |
| 174 |
foreach ( $cards as $card_id => $entry ) { |
| 175 |
openstation_station_home_card_registry( $card_id, $entry ); |
| 176 |
} |
| 177 |
return true; |
| 178 |
} |
| 179 |
|
| 180 |
/** |
| 181 |
* Return the post-filter card registry in deterministic display order. |
| 182 |
* |
| 183 |
* @return array[] Entries keyed by card id. |
| 184 |
*/ |
| 185 |
function openstation_station_home_get_registered_cards() { |
| 186 |
$cards = openstation_station_home_card_registry(); |
| 187 |
|
| 188 |
/** |
| 189 |
* Filters the registered Station Home cards for the current user. |
| 190 |
* |
| 191 |
* Plugins may add, remove, or replace entries. Added entries must use the |
| 192 |
* same shape accepted by `openstation_register_station_home_card()`. |
| 193 |
* |
| 194 |
* @param array[] $cards Entries keyed by card id. |
| 195 |
* @param int $user_id Current user id. |
| 196 |
*/ |
| 197 |
$cards = apply_filters( 'openstation_station_home_cards', $cards, get_current_user_id() ); |
| 198 |
if ( ! is_array( $cards ) ) { |
| 199 |
return array(); |
| 200 |
} |
| 201 |
|
| 202 |
$normalized = array(); |
| 203 |
foreach ( $cards as $key => $entry ) { |
| 204 |
if ( ! is_array( $entry ) || ! is_callable( $entry['callback'] ?? null ) ) { |
| 205 |
continue; |
| 206 |
} |
| 207 |
$allowed = true; |
| 208 |
foreach ( (array) ( $entry['capabilities'] ?? array() ) as $capability ) { |
| 209 |
if ( ! current_user_can( (string) $capability ) ) { |
| 210 |
$allowed = false; |
| 211 |
break; |
| 212 |
} |
| 213 |
} |
| 214 |
if ( ! $allowed ) { |
| 215 |
continue; |
| 216 |
} |
| 217 |
$id = sanitize_key( (string) ( $entry['id'] ?? $key ) ); |
| 218 |
$label = sanitize_text_field( (string) ( $entry['label'] ?? '' ) ); |
| 219 |
if ( '' === $id || '' === $label ) { |
| 220 |
continue; |
| 221 |
} |
| 222 |
$normalized[ $id ] = array( |
| 223 |
'id' => $id, |
| 224 |
'label' => $label, |
| 225 |
'description' => sanitize_textarea_field( (string) ( $entry['description'] ?? '' ) ), |
| 226 |
'provider' => sanitize_text_field( (string) ( $entry['provider'] ?? '' ) ), |
| 227 |
'icon' => openstation_sanitize_dock_icon( (string) ( $entry['icon'] ?? 'dashicons-admin-plugins' ) ), |
| 228 |
'callback' => $entry['callback'], |
| 229 |
'default_enabled' => (bool) ( $entry['default_enabled'] ?? false ), |
| 230 |
'order' => (int) ( $entry['order'] ?? 10 ), |
| 231 |
); |
| 232 |
} |
| 233 |
|
| 234 |
uasort( |
| 235 |
$normalized, |
| 236 |
static function ( $left, $right ) { |
| 237 |
$order = $left['order'] <=> $right['order']; |
| 238 |
return 0 !== $order ? $order : strcasecmp( $left['label'], $right['label'] ); |
| 239 |
} |
| 240 |
); |
| 241 |
|
| 242 |
return $normalized; |
| 243 |
} |
| 244 |
|
| 245 |
/** |
| 246 |
* Read a user's explicit Station Home card choices. |
| 247 |
* |
| 248 |
* @param int $user_id User id. Defaults to the current user. |
| 249 |
* @return array<string, bool> |
| 250 |
*/ |
| 251 |
function openstation_station_home_get_card_preferences( $user_id = 0 ) { |
| 252 |
$user_id = $user_id > 0 ? (int) $user_id : get_current_user_id(); |
| 253 |
$stored = get_user_meta( $user_id, OPENSTATION_STATION_HOME_CARD_PREFERENCES_META, true ); |
| 254 |
if ( ! is_array( $stored ) ) { |
| 255 |
return array(); |
| 256 |
} |
| 257 |
|
| 258 |
$preferences = array(); |
| 259 |
foreach ( $stored as $id => $enabled ) { |
| 260 |
$id = sanitize_key( (string) $id ); |
| 261 |
if ( '' !== $id ) { |
| 262 |
$preferences[ $id ] = (bool) $enabled; |
| 263 |
} |
| 264 |
} |
| 265 |
return $preferences; |
| 266 |
} |
| 267 |
|
| 268 |
/** |
| 269 |
* Resolve a card's effective per-user enabled state. |
| 270 |
* |
| 271 |
* @param string $id Card id. |
| 272 |
* @param array $entry Registry entry. |
| 273 |
* @param array $preferences Explicit preference map. |
| 274 |
* @return bool |
| 275 |
*/ |
| 276 |
function openstation_station_home_card_is_enabled( $id, $entry, $preferences ) { |
| 277 |
if ( array_key_exists( $id, $preferences ) ) { |
| 278 |
return (bool) $preferences[ $id ]; |
| 279 |
} |
| 280 |
return (bool) $entry['default_enabled']; |
| 281 |
} |
| 282 |
|
| 283 |
/** |
| 284 |
* Build public preference rows and enabled card payloads for the snapshot. |
| 285 |
* |
| 286 |
* @param array[] $cards Registered card entries. |
| 287 |
* @param array<string, bool> $preferences Explicit user preferences. |
| 288 |
* @return array{cards: array[], preferences: array[]} |
| 289 |
*/ |
| 290 |
function openstation_station_home_build_cards( $cards, $preferences ) { |
| 291 |
$payload = array(); |
| 292 |
$preference_rows = array(); |
| 293 |
$user_id = get_current_user_id(); |
| 294 |
|
| 295 |
foreach ( $cards as $id => $entry ) { |
| 296 |
$enabled = openstation_station_home_card_is_enabled( $id, $entry, $preferences ); |
| 297 |
$preference_rows[] = array( |
| 298 |
'id' => $id, |
| 299 |
'label' => $entry['label'], |
| 300 |
'description' => $entry['description'], |
| 301 |
'provider' => $entry['provider'], |
| 302 |
'icon' => $entry['icon'], |
| 303 |
'enabled' => $enabled, |
| 304 |
'defaultEnabled' => (bool) $entry['default_enabled'], |
| 305 |
); |
| 306 |
|
| 307 |
if ( ! $enabled ) { |
| 308 |
continue; |
| 309 |
} |
| 310 |
|
| 311 |
try { |
| 312 |
$data = call_user_func( $entry['callback'], $user_id, $entry ); |
| 313 |
} catch ( Throwable $error ) { |
| 314 |
/** |
| 315 |
* Fires when a contributed card callback throws. |
| 316 |
* |
| 317 |
* @param Throwable $error The callback error. |
| 318 |
* @param string $id Card id. |
| 319 |
* @param array $entry Registry entry. |
| 320 |
*/ |
| 321 |
do_action( 'openstation_station_home_card_error', $error, $id, $entry ); |
| 322 |
continue; |
| 323 |
} |
| 324 |
if ( is_wp_error( $data ) || ! is_array( $data ) ) { |
| 325 |
continue; |
| 326 |
} |
| 327 |
|
| 328 |
/** |
| 329 |
* Filters an enabled card's dynamic data before sanitization. |
| 330 |
* |
| 331 |
* @param array $data Callback data. |
| 332 |
* @param string $id Card id. |
| 333 |
* @param array $entry Registry entry. |
| 334 |
* @param int $user_id Current user id. |
| 335 |
*/ |
| 336 |
$data = apply_filters( 'openstation_station_home_card_data', $data, $id, $entry, $user_id ); |
| 337 |
if ( ! is_array( $data ) ) { |
| 338 |
continue; |
| 339 |
} |
| 340 |
|
| 341 |
$tone = (string) ( $data['tone'] ?? 'neutral' ); |
| 342 |
if ( ! in_array( $tone, array( 'neutral', 'info', 'success', 'warning', 'danger' ), true ) ) { |
| 343 |
$tone = 'neutral'; |
| 344 |
} |
| 345 |
|
| 346 |
$payload[] = array( |
| 347 |
'id' => $id, |
| 348 |
'label' => $entry['label'], |
| 349 |
'description' => $entry['description'], |
| 350 |
'provider' => $entry['provider'], |
| 351 |
'icon' => $entry['icon'], |
| 352 |
'value' => sanitize_text_field( (string) ( $data['value'] ?? '' ) ), |
| 353 |
'detail' => sanitize_textarea_field( (string) ( $data['detail'] ?? '' ) ), |
| 354 |
'url' => esc_url_raw( (string) ( $data['url'] ?? '' ) ), |
| 355 |
'actionLabel' => sanitize_text_field( (string) ( $data['action_label'] ?? '' ) ), |
| 356 |
'external' => (bool) ( $data['external'] ?? false ), |
| 357 |
'tone' => $tone, |
| 358 |
); |
| 359 |
} |
| 360 |
|
| 361 |
return array( |
| 362 |
'cards' => $payload, |
| 363 |
'preferences' => $preference_rows, |
| 364 |
); |
| 365 |
} |
| 366 |
|