PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
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 / openers.php

openers.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.8, at includes/desktop-files/openers.php

348 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — file-opener registry.
4 *
5 * An "opener" is the equivalent of a default-app association in a
6 * desktop OS: it answers the question "what should happen when the
7 * user double-clicks a `post` file?" with a discrete choice — open
8 * Gutenberg, open the Classic Editor, open someone-else's modal,
9 * etc. Multiple openers can register for the same file-type slug;
10 * the user picks their preferred one in OS Settings → File
11 * Associations (Phase 5), and the JS side resolves on every
12 * double-click using this chain:
13 *
14 * 1. The user's per-type override (`desktop_mode_file_associations`
15 * user meta).
16 * 2. The opener marked `is_default => true` for the type.
17 * 3. The first registered opener for the type (sort order).
18 * 4. No-op (the file simply can't be opened).
19 *
20 * The PHP side is metadata-only — opener handlers (URL builders,
21 * JS callbacks) live on the JS side because closures don't
22 * serialize across the shell payload. The JS module mirrors the
23 * registration surface and ships a `handler` field with the
24 * actual logic; the PHP entry feeds the OS Settings UI and
25 * validates user-meta choices against the known set.
26 *
27 * @package WPDesktopMode
28 */
29
30 defined( 'ABSPATH' ) || exit;
31
32 /** User-meta key holding `{ type => opener_id, … }`. */
33 define( 'DESKTOP_MODE_FILE_ASSOCIATIONS_META', 'desktop_mode_file_associations' );
34
35 /**
36 * Internal static-store registry. Same pattern as the file-type
37 * and wallpaper registries.
38 *
39 * @internal
40 */
41 function desktop_mode_file_opener_registry( $id = '', $entry = null ) {
42 static $store = array();
43
44 if ( '' === (string) $id ) {
45 return $store;
46 }
47 if ( null !== $entry ) {
48 $store[ (string) $id ] = $entry;
49 }
50 return isset( $store[ (string) $id ] ) ? $store[ (string) $id ] : null;
51 }
52
53 /**
54 * Registers a file-opener.
55 *
56 * @param string $id Opener id, e.g. `'gutenberg'`. Unique across
57 * openers; collisions overwrite (late wins).
58 * @param array $args {
59 * @type string $label Required. Picker label.
60 * @type string[] $types Required. File-type slugs this
61 * opener handles (`['post']`,
62 * `['post','page']`, etc.).
63 * @type bool $is_default Whether this opener is the
64 * ship-time default for ALL its
65 * types. The first matching
66 * default wins (registration
67 * order). Default false.
68 * @type int $sort Sort order in pickers. Default 100.
69 * @type string $script Optional handle the shell loads
70 * on activation so JS-side handlers
71 * defined in a plugin bundle appear
72 * without a page reload (Phase 5+).
73 * @type string[] $capabilities Gate: ALL caps must match.
74 * }
75 * @return true|WP_Error
76 */
77 function desktop_mode_register_file_opener( $id, $args = array() ) {
78 $id = (string) $id;
79 if ( '' === $id ) {
80 return desktop_mode_registration_error(
81 'desktop_mode_missing_id',
82 __( 'Opener id is required.', 'desktop-mode' )
83 );
84 }
85
86 $defaults = array(
87 'label' => '',
88 'types' => array(),
89 'is_default' => false,
90 'sort' => 100,
91 'script' => '',
92 'capabilities' => array(),
93 );
94 $args = wp_parse_args( $args, $defaults );
95
96 foreach ( (array) $args['capabilities'] as $cap ) {
97 if ( ! current_user_can( (string) $cap ) ) {
98 return desktop_mode_registration_error(
99 'desktop_mode_capability_denied',
100 sprintf(
101 /* translators: %s: capability slug. */
102 __( 'Current user lacks the %s capability required to register this opener.', 'desktop-mode' ),
103 (string) $cap
104 ),
105 array( 'capability' => (string) $cap, 'id' => $id )
106 );
107 }
108 }
109
110 if ( '' === (string) $args['label'] ) {
111 return desktop_mode_registration_error(
112 'desktop_mode_missing_label',
113 __( 'Opener registration requires a non-empty `label`.', 'desktop-mode' ),
114 array( 'id' => $id )
115 );
116 }
117
118 $types = array_values( array_filter( array_map( 'strval', (array) $args['types'] ) ) );
119 if ( empty( $types ) ) {
120 return desktop_mode_registration_error(
121 'desktop_mode_missing_types',
122 __( 'Opener registration requires at least one file `type`.', 'desktop-mode' ),
123 array( 'id' => $id )
124 );
125 }
126
127 $entry = array(
128 'id' => $id,
129 'label' => (string) $args['label'],
130 'types' => $types,
131 'is_default' => (bool) $args['is_default'],
132 'sort' => (int) $args['sort'],
133 'script' => (string) $args['script'],
134 );
135 desktop_mode_file_opener_registry( $id, $entry );
136
137 /**
138 * Fires after a file opener is successfully registered. Does
139 * NOT fire on `WP_Error` returns.
140 *
141 * @param string $id Opener id.
142 * @param array $entry Stored registry entry.
143 */
144 do_action( 'desktop_mode_file_opener_registered', $id, $entry );
145
146 return true;
147 }
148
149 /**
150 * Returns every registered opener, sorted by `sort` then label.
151 *
152 * @return array[]
153 */
154 function desktop_mode_get_file_openers() {
155 $registry = desktop_mode_file_opener_registry();
156 if ( ! is_array( $registry ) || empty( $registry ) ) {
157 return array();
158 }
159
160 /**
161 * Filters the opener registry before consumers see it. Plugins
162 * can hide built-ins, swap labels, or rearrange sort order.
163 *
164 * @param array[] $registry Registered openers keyed by id.
165 */
166 $registry = apply_filters( 'desktop_mode_file_openers', $registry );
167 if ( ! is_array( $registry ) ) {
168 return array();
169 }
170
171 $entries = array_values( $registry );
172 usort(
173 $entries,
174 static function ( $a, $b ) {
175 $sa = isset( $a['sort'] ) ? (int) $a['sort'] : 100;
176 $sb = isset( $b['sort'] ) ? (int) $b['sort'] : 100;
177 if ( $sa !== $sb ) {
178 return $sa - $sb;
179 }
180 return strcmp(
181 isset( $a['label'] ) ? (string) $a['label'] : '',
182 isset( $b['label'] ) ? (string) $b['label'] : ''
183 );
184 }
185 );
186 return $entries;
187 }
188
189 /**
190 * Returns openers that handle a given file type.
191 *
192 * @param string $type File-type slug.
193 * @return array[]
194 */
195 function desktop_mode_get_file_openers_for_type( $type ) {
196 $type = (string) $type;
197 if ( '' === $type ) {
198 return array();
199 }
200 return array_values(
201 array_filter(
202 desktop_mode_get_file_openers(),
203 static function ( $entry ) use ( $type ) {
204 return in_array( $type, (array) $entry['types'], true );
205 }
206 )
207 );
208 }
209
210 /**
211 * Resolves the opener id that should open a `(type, ref)` tuple
212 * for `$user_id`. Returns the id only — the JS side owns
213 * dispatch.
214 *
215 * Resolution chain:
216 * 1. User-meta override (per type).
217 * 2. `is_default` opener for the type.
218 * 3. First opener for the type (already sort-ordered).
219 *
220 * @param string $type File-type slug.
221 * @param int $user_id Viewer.
222 * @return string Opener id, or empty string when nothing matches.
223 */
224 function desktop_mode_resolve_file_opener_id( $type, $user_id ) {
225 $candidates = desktop_mode_get_file_openers_for_type( $type );
226 if ( empty( $candidates ) ) {
227 return '';
228 }
229 $by_id = array();
230 foreach ( $candidates as $entry ) {
231 $by_id[ $entry['id'] ] = $entry;
232 }
233
234 // 1. User override.
235 $override = '';
236 if ( $user_id > 0 ) {
237 $assoc = get_user_meta( (int) $user_id, DESKTOP_MODE_FILE_ASSOCIATIONS_META, true );
238 if ( is_array( $assoc ) && isset( $assoc[ $type ] ) ) {
239 $override = (string) $assoc[ $type ];
240 }
241 }
242 if ( '' !== $override && isset( $by_id[ $override ] ) ) {
243 $resolved = $override;
244 } else {
245 // 2. Default opener.
246 $resolved = '';
247 foreach ( $candidates as $entry ) {
248 if ( ! empty( $entry['is_default'] ) ) {
249 $resolved = (string) $entry['id'];
250 break;
251 }
252 }
253 // 3. Fall back to the first registered opener.
254 if ( '' === $resolved ) {
255 $resolved = (string) $candidates[0]['id'];
256 }
257 }
258
259 /**
260 * Filters the resolved opener id for a `(type, user_id)`
261 * tuple. Plugins can override the user's choice — useful for
262 * role-based forced associations or for AB-testing a new
263 * editor before promoting it to default.
264 *
265 * @param string $resolved Resolved opener id.
266 * @param string $type File-type slug.
267 * @param int $user_id Viewer.
268 */
269 return (string) apply_filters( 'desktop_mode_resolve_file_opener', $resolved, $type, $user_id );
270 }
271
272 /**
273 * Builds the openers payload sent to the shell.
274 *
275 * @return array[]
276 */
277 function desktop_mode_build_file_openers_payload() {
278 $entries = desktop_mode_get_file_openers();
279 if ( empty( $entries ) ) {
280 return array();
281 }
282 $out = array();
283 foreach ( $entries as $entry ) {
284 $handle = isset( $entry['script'] ) ? (string) $entry['script'] : '';
285 $payload = '' !== $handle
286 ? desktop_mode_resolve_script_payload( $handle )
287 : array(
288 'url' => '',
289 'before' => array(),
290 'after' => array(),
291 'l10n' => array(),
292 'translations' => '',
293 );
294 $out[] = array(
295 'id' => (string) $entry['id'],
296 'label' => (string) $entry['label'],
297 'types' => array_values( (array) $entry['types'] ),
298 'isDefault' => (bool) $entry['is_default'],
299 'sort' => (int) $entry['sort'],
300 'scriptUrl' => $payload['url'],
301 'scriptHandle' => $handle,
302 'scriptBefore' => $payload['before'],
303 'scriptAfter' => $payload['after'],
304 'scriptL10n' => $payload['l10n'],
305 'scriptTranslations' => $payload['translations'],
306 );
307 }
308 return $out;
309 }
310
311 /**
312 * Returns the current user's `{ type => opener_id }` association
313 * map, sanitized against the registered openers (entries pointing
314 * at unknown ids are dropped from the returned map but kept in
315 * meta — a deactivated plugin that comes back later resumes its
316 * choice).
317 *
318 * @param int $user_id Viewer.
319 * @return array<string,string>
320 */
321 function desktop_mode_get_user_file_associations( $user_id ) {
322 if ( $user_id <= 0 ) {
323 return array();
324 }
325 $raw = get_user_meta( (int) $user_id, DESKTOP_MODE_FILE_ASSOCIATIONS_META, true );
326 if ( ! is_array( $raw ) ) {
327 return array();
328 }
329 $openers = desktop_mode_get_file_openers();
330 $known = array();
331 foreach ( $openers as $entry ) {
332 $known[ (string) $entry['id'] ] = (array) $entry['types'];
333 }
334 $out = array();
335 foreach ( $raw as $type => $opener_id ) {
336 $type = (string) $type;
337 $opener_id = (string) $opener_id;
338 if ( ! isset( $known[ $opener_id ] ) ) {
339 continue;
340 }
341 if ( ! in_array( $type, $known[ $opener_id ], true ) ) {
342 continue;
343 }
344 $out[ $type ] = $opener_id;
345 }
346 return $out;
347 }
348