| 1 |
<?php |
| 2 |
/** |
| 3 |
* Plugins app — the "OpenStation plugins" (Featured) tab's source. |
| 4 |
* |
| 5 |
* Part of the `desktop-mode-plugins` app: required by `plugins.os.php`, |
| 6 |
* plain `.php` on purpose — only `*.os.php` files are app entries to |
| 7 |
* the framework loader. A curated slug list (wp.org has no usable |
| 8 |
* `requires_plugins` filter, so the seed is maintained by hand and |
| 9 |
* filterable) topped up at runtime by scanning the popular feed for |
| 10 |
* rows that declare the `desktop-mode` dependency, served over |
| 11 |
* admin-ajax (`plugins_api()` is admin-only) and cached for an hour. |
| 12 |
* |
| 13 |
* @package OpenStation |
| 14 |
*/ |
| 15 |
|
| 16 |
// Direct access, unless a standalone host is booting on bare PHP. |
| 17 |
if ( ! defined( 'ABSPATH' ) ) { |
| 18 |
defined( 'OPENSTATION_STANDALONE' ) || exit; |
| 19 |
} |
| 20 |
|
| 21 |
/** |
| 22 |
* Curated list of slugs that lead the Featured tab. |
| 23 |
* |
| 24 |
* Hand-picked because wp.org's `plugins_api` does not surface a real |
| 25 |
* "filter by `requires_plugins`" query — passing `requires_plugins` to |
| 26 |
* `query_plugins` is silently ignored and returns the unfiltered repo. |
| 27 |
* Until the directory grows a usable filter, we maintain the seed list |
| 28 |
* here and let downstream plugins amend it via the filter below. |
| 29 |
* |
| 30 |
* Slug-only — the AJAX handler hydrates each entry through |
| 31 |
* `plugins_api( 'plugin_information' )` so the card has up-to-date |
| 32 |
* icons, descriptions, and install counts without us caching them. |
| 33 |
* |
| 34 |
* @return string[] List of wp.org plugin slugs. |
| 35 |
*/ |
| 36 |
function openstation_plugins_window_featured_slugs() { |
| 37 |
$slugs = array( |
| 38 |
// The author of this plugin forgot to declare OpenStation as a |
| 39 |
// dependency — surfacing it here makes sure openstation users |
| 40 |
// discover it anyway. Once the `requires_plugins` query lands on |
| 41 |
// wp.org we can remove the manual seed. |
| 42 |
'odd-outlandish-desktop-decorator', |
| 43 |
'allterrain-forms', |
| 44 |
'allterrain-photo-editor', |
| 45 |
'allterrain-maia', |
| 46 |
); |
| 47 |
|
| 48 |
/** |
| 49 |
* Filter the curated list of featured-plugin slugs. |
| 50 |
* |
| 51 |
* Plugin authors can prepend (or remove) entries to recommend their |
| 52 |
* own Desktop-Mode-aware add-ons. Order is preserved — the first |
| 53 |
* slug renders first in the gallery. |
| 54 |
* |
| 55 |
* @param string[] $slugs Plugin slugs. |
| 56 |
*/ |
| 57 |
$slugs = (array) apply_filters( 'openstation_plugins_featured_slugs', $slugs ); |
| 58 |
$slugs = array_values( |
| 59 |
array_unique( |
| 60 |
array_filter( |
| 61 |
array_map( |
| 62 |
static function ( $s ) { |
| 63 |
return sanitize_key( (string) $s ); |
| 64 |
}, |
| 65 |
$slugs |
| 66 |
) |
| 67 |
) |
| 68 |
) |
| 69 |
); |
| 70 |
return $slugs; |
| 71 |
} |
| 72 |
|
| 73 |
/** |
| 74 |
* `wp_ajax_openstation_plugins_featured` — return the Featured tab's |
| 75 |
* curated + auto-discovered list of plugins that integrate with Desktop |
| 76 |
* Mode. |
| 77 |
* |
| 78 |
* Composition: |
| 79 |
* 1. Curated slugs from `openstation_plugins_window_featured_slugs()`, |
| 80 |
* hydrated via `plugins_api( 'plugin_information' )` so the card |
| 81 |
* payload is always fresh. |
| 82 |
* 2. Auto-discovered slugs from `plugins_api( 'query_plugins' )` whose |
| 83 |
* `requires_plugins` array contains `openstation`. wp.org has no |
| 84 |
* server-side filter for this today, so we run a broad query and |
| 85 |
* filter server-side. Deduped against the curated set. |
| 86 |
* |
| 87 |
* Body params: (none) |
| 88 |
* |
| 89 |
* Cached for 1h. Failures cached for 15m so a flaky wp.org doesn't |
| 90 |
* hammer the API on every tab open. |
| 91 |
*/ |
| 92 |
function openstation_plugins_window_ajax_featured() { |
| 93 |
$guard = openstation_plugins_window_ajax_guard( 'install_plugins' ); |
| 94 |
if ( is_wp_error( $guard ) ) { |
| 95 |
openstation_plugins_window_ajax_error( $guard ); |
| 96 |
return; |
| 97 |
} |
| 98 |
|
| 99 |
openstation_plugins_window_load_plugins_api(); |
| 100 |
|
| 101 |
$cache_key = 'dm_pwfeatured_v1'; |
| 102 |
$cached = get_transient( $cache_key ); |
| 103 |
if ( false !== $cached && is_array( $cached ) ) { |
| 104 |
wp_send_json_success( $cached ); |
| 105 |
return; |
| 106 |
} |
| 107 |
|
| 108 |
$plugins = array(); |
| 109 |
$seen_slugs = array(); |
| 110 |
$fields = array( |
| 111 |
'icons' => true, |
| 112 |
'banners' => false, |
| 113 |
'short_description' => true, |
| 114 |
'description' => false, |
| 115 |
'sections' => false, |
| 116 |
'screenshots' => false, |
| 117 |
'rating' => true, |
| 118 |
'ratings' => false, |
| 119 |
'num_ratings' => true, |
| 120 |
'active_installs' => true, |
| 121 |
'last_updated' => true, |
| 122 |
'tested' => true, |
| 123 |
'requires' => true, |
| 124 |
'requires_php' => true, |
| 125 |
'requires_plugins' => true, |
| 126 |
'homepage' => true, |
| 127 |
'compatibility' => false, |
| 128 |
'group' => false, |
| 129 |
'contributors' => false, |
| 130 |
'donate_link' => false, |
| 131 |
); |
| 132 |
|
| 133 |
// ─── 1. Curated slugs ───────────────────────────────────────────── |
| 134 |
$curated = openstation_plugins_window_featured_slugs(); |
| 135 |
foreach ( $curated as $slug ) { |
| 136 |
if ( isset( $seen_slugs[ $slug ] ) ) { |
| 137 |
continue; |
| 138 |
} |
| 139 |
$info = plugins_api( |
| 140 |
'plugin_information', |
| 141 |
array( |
| 142 |
'slug' => $slug, |
| 143 |
'fields' => $fields, |
| 144 |
) |
| 145 |
); |
| 146 |
if ( is_wp_error( $info ) || ! is_object( $info ) ) { |
| 147 |
// Skip — a curated slug that 404s shouldn't tank the whole |
| 148 |
// tab. |
| 149 |
continue; |
| 150 |
} |
| 151 |
$row = (array) $info; |
| 152 |
$row['featured'] = true; |
| 153 |
$plugins[] = $row; |
| 154 |
$seen_slugs[ $slug ] = true; |
| 155 |
} |
| 156 |
|
| 157 |
// ─── 2. Auto-discover via `requires_plugins` ────────────────────── |
| 158 |
// Best-effort scan: pull the top of the directory and keep rows that |
| 159 |
// declare openstation as a dependency. The wp.org `query_plugins` |
| 160 |
// API ignores `requires_plugins` as a filter, so we have to fetch + |
| 161 |
// sift locally. Scope is intentionally small (100 most-popular rows) |
| 162 |
// to keep the request bounded; as the ecosystem grows we'll widen |
| 163 |
// or replace with a real dependency query when wp.org ships one. |
| 164 |
$discovered = plugins_api( |
| 165 |
'query_plugins', |
| 166 |
array( |
| 167 |
'browse' => 'popular', |
| 168 |
'page' => 1, |
| 169 |
'per_page' => 100, |
| 170 |
'fields' => $fields, |
| 171 |
) |
| 172 |
); |
| 173 |
if ( ! is_wp_error( $discovered ) && isset( $discovered->plugins ) && is_array( $discovered->plugins ) ) { |
| 174 |
foreach ( $discovered->plugins as $candidate ) { |
| 175 |
$candidate = (array) $candidate; |
| 176 |
$slug = isset( $candidate['slug'] ) ? sanitize_key( (string) $candidate['slug'] ) : ''; |
| 177 |
if ( '' === $slug || isset( $seen_slugs[ $slug ] ) ) { |
| 178 |
continue; |
| 179 |
} |
| 180 |
$requires = isset( $candidate['requires_plugins'] ) ? (array) $candidate['requires_plugins'] : array(); |
| 181 |
// The wp.org directory slug, not the brand — `requires_plugins` |
| 182 |
// rows resolve against our plugin folder name. |
| 183 |
if ( ! in_array( 'desktop-mode', $requires, true ) ) { |
| 184 |
continue; |
| 185 |
} |
| 186 |
$candidate['featured'] = false; |
| 187 |
$plugins[] = $candidate; |
| 188 |
$seen_slugs[ $slug ] = true; |
| 189 |
} |
| 190 |
} |
| 191 |
|
| 192 |
// `count( $plugins ) - count( $curated )` can underflow when a |
| 193 |
// curated slug fails hydration (slug typo, plugin temporarily |
| 194 |
// delisted from wp.org, plugins_api returning WP_Error). The JS |
| 195 |
// only uses `discovered` for informational headers, so a negative |
| 196 |
// number wouldn't crash anything, but it does read as a bug. |
| 197 |
// Clamp at zero so the count remains a defensible "non-curated rows |
| 198 |
// in the payload." |
| 199 |
$payload = array( |
| 200 |
'plugins' => array_values( $plugins ), |
| 201 |
'info' => array( |
| 202 |
'curated' => count( $curated ), |
| 203 |
'discovered' => max( 0, count( $plugins ) - count( $curated ) ), |
| 204 |
'results' => count( $plugins ), |
| 205 |
), |
| 206 |
); |
| 207 |
|
| 208 |
/** |
| 209 |
* Filter the Featured tab payload before it's cached + sent. |
| 210 |
* |
| 211 |
* Use this to inject server-side curated rows (e.g. premium / |
| 212 |
* private plugins not on wp.org), or to enforce a hard cap on the |
| 213 |
* response. |
| 214 |
* |
| 215 |
* @param array $payload `{ plugins: [...], info: {...} }`. |
| 216 |
* @param array $curated Curated slug list. |
| 217 |
*/ |
| 218 |
$payload = (array) apply_filters( |
| 219 |
'openstation_plugins_featured_response', |
| 220 |
$payload, |
| 221 |
$curated |
| 222 |
); |
| 223 |
|
| 224 |
set_transient( $cache_key, $payload, HOUR_IN_SECONDS ); |
| 225 |
wp_send_json_success( $payload ); |
| 226 |
} |
| 227 |
add_action( 'wp_ajax_openstation_plugins_featured', 'openstation_plugins_window_ajax_featured' ); |
| 228 |
|