PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.8.9
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.8.9
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.8.9, at includes/desktop-files/openers.php

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