-
Notifications
You must be signed in to change notification settings - Fork 4.8k
Expand file tree
/
Copy pathcollaboration.php
More file actions
543 lines (497 loc) · 18.3 KB
/
collaboration.php
File metadata and controls
543 lines (497 loc) · 18.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
<?php
/**
* Bootstraps collaborative editing.
*
* @package gutenberg
*/
if ( ! class_exists( 'WP_Sync_Post_Meta_Storage' ) ) {
require_once __DIR__ . '/interface-wp-sync-storage.php';
require_once __DIR__ . '/class-wp-sync-post-meta-storage.php';
require_once __DIR__ . '/class-wp-http-polling-sync-server.php';
}
if ( ! function_exists( 'gutenberg_register_sync_storage_post_type' ) ) {
/**
* Registers the custom post type for sync storage.
*/
function gutenberg_register_sync_storage_post_type() {
register_post_type(
'wp_sync_storage',
array(
'labels' => array(
'name' => __( 'Sync Updates', 'gutenberg' ),
'singular_name' => __( 'Sync Update', 'gutenberg' ),
),
'public' => false,
'hierarchical' => false,
'capabilities' => array(
'read' => 'do_not_allow',
'read_private_posts' => 'do_not_allow',
'create_posts' => 'do_not_allow',
'publish_posts' => 'do_not_allow',
'edit_posts' => 'do_not_allow',
'edit_others_posts' => 'do_not_allow',
'edit_published_posts' => 'do_not_allow',
'delete_posts' => 'do_not_allow',
'delete_others_posts' => 'do_not_allow',
'delete_published_posts' => 'do_not_allow',
),
'map_meta_cap' => false,
'publicly_queryable' => false,
'query_var' => false,
'rewrite' => false,
'show_in_menu' => false,
'show_in_rest' => false,
'show_ui' => false,
'supports' => array( 'custom-fields' ),
)
);
}
add_action( 'init', 'gutenberg_register_sync_storage_post_type' );
}
if ( ! function_exists( 'gutenberg_register_collaboration_rest_routes' ) ) {
/**
* Registers REST API routes for collaborative editing.
*/
function gutenberg_register_collaboration_rest_routes(): void {
$sync_storage = new WP_Sync_Post_Meta_Storage();
$sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage );
$sync_server->register_routes();
}
add_action( 'rest_api_init', 'gutenberg_register_collaboration_rest_routes' );
}
if ( ! function_exists( 'wp_collaboration_register_meta' ) ) {
/**
* Registers post meta for persisting CRDT documents.
*/
function gutenberg_rest_api_crdt_post_meta() {
// This string must match WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE in @wordpress/sync.
$persisted_crdt_post_meta_key = '_crdt_document';
register_meta(
'post',
$persisted_crdt_post_meta_key,
array(
'auth_callback' => static function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool {
return user_can( $user_id, 'edit_post', $object_id );
},
/*
* Revisions must be disabled because we always want to preserve
* the latest persisted CRDT document, even when a revision is restored.
* This ensures that we can continue to apply updates to a shared document
* and peers can simply merge the restored revision like any other incoming
* update.
*
* If we want to persist CRDT documents alongside revisions in the
* future, we should do so in a separate meta key.
*/
'revisions_enabled' => false,
'show_in_rest' => true,
'single' => true,
'type' => 'string',
)
);
}
add_action( 'init', 'gutenberg_rest_api_crdt_post_meta' );
}
if ( ! function_exists( 'gutenberg_crdt_intercept_post_meta_update' ) ) {
/**
* Intercepts post meta updates for the persisted CRDT document to
* implement optimistic concurrency control. Clients embed a `baseVersion`
* field in the serialized document. Before writing, this filter checks
* that the client's base version matches the version currently stored on
* the server. When versions match the write proceeds and the version is
* incremented atomically using a compare-and-swap on the database row.
*
* When the stored version does not match the client's base version the
* write is rejected — another client has already updated the document.
* The rejected client will retry after receiving the latest version
* through the next server response.
*
* @param mixed $check Whether to allow the update. Returning a
* non-null value short-circuits update_metadata().
* @param int $object_id Post ID.
* @param string $meta_key Meta key being updated.
* @param mixed $meta_value New meta value (JSON string).
* @return mixed Null to allow WordPress to proceed, false to reject, true
* when the write was handled directly.
*/
function gutenberg_crdt_intercept_post_meta_update( $check, $object_id, $meta_key, $meta_value ) {
if ( '_crdt_document' !== $meta_key ) {
return $check;
}
$incoming = json_decode( $meta_value, true );
if ( ! is_array( $incoming ) ) {
return $check;
}
$base_version = (int) ( $incoming['baseVersion'] ?? 0 );
global $wpdb;
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT meta_id, meta_value FROM $wpdb->postmeta
WHERE post_id = %d AND meta_key = %s",
$object_id,
'_crdt_document'
)
);
if ( $row ) {
// Existing meta — check version before writing.
$current = json_decode( $row->meta_value, true );
$current_version = (int) ( is_array( $current ) ? ( $current['baseVersion'] ?? 0 ) : 0 );
if ( $current_version !== $base_version ) {
return false; // Stale — client's base version does not match the server.
}
$incoming['baseVersion'] = $current_version + 1;
$new_value = wp_json_encode( $incoming );
// Atomic compare-and-swap: only update if the row hasn't changed
// since we last read it.
$affected = $wpdb->update(
$wpdb->postmeta,
array( 'meta_value' => $new_value ),
array(
'meta_id' => $row->meta_id,
'meta_value' => $row->meta_value,
)
);
if ( 0 === $affected ) {
return false; // Lost the race — another request updated first.
}
wp_cache_delete( $object_id, 'post_meta' );
/**
* Fires immediately after a CRDT-document post meta row is updated.
* Mirrors WordPress Core's `updated_post_meta` action to keep
* caches and hooks consistent.
*
* @param int $meta_id Meta ID.
* @param int $object_id Post ID.
* @param string $meta_key Meta key.
* @param mixed $meta_value Meta value.
*/
do_action( 'updated_post_meta', $row->meta_id, $object_id, $meta_key, $meta_value );
return true; // Handled — short-circuit WordPress.
}
// First-time write: no stored row yet. Let WordPress do the INSERT.
// The `pre_update_post__crdt_document` filter will inject
// baseVersion=1 before the row is created.
return $check;
}
add_filter( 'update_post_metadata', 'gutenberg_crdt_intercept_post_meta_update', 10, 4 );
}
if ( ! function_exists( 'gutenberg_crdt_set_initial_base_version' ) ) {
/**
* Sets the initial `baseVersion` on the first write of a persisted CRDT
* document. WordPress's `update_post_metadata` filter cannot modify the
* value for INSERTs (only short-circuit them), so this companion filter
* injects `baseVersion=1` when the stored row doesn't exist yet.
*
* @param mixed $meta_value New meta value (JSON string).
* @return mixed Modified meta value with baseVersion set, or the original.
*/
function gutenberg_crdt_set_initial_base_version( $meta_value ) {
$decoded = json_decode( $meta_value, true );
if ( is_array( $decoded ) && empty( $decoded['baseVersion'] ) ) {
$decoded['baseVersion'] = 1;
return wp_json_encode( $decoded );
}
return $meta_value;
}
add_filter( 'pre_update_post__crdt_document', 'gutenberg_crdt_set_initial_base_version', 10, 1 );
}
if ( ! function_exists( 'wp_collaboration_inject_setting' ) ) {
/**
* Registers the real-time collaboration setting.
*/
function gutenberg_register_real_time_collaboration_setting() {
$option_name = 'wp_collaboration_enabled';
register_setting(
'writing',
$option_name,
array(
'type' => 'boolean',
'description' => __( 'Enable Real-Time Collaboration', 'gutenberg' ),
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => true,
'show_in_rest' => true,
)
);
add_settings_field(
$option_name,
__( 'Collaboration', 'gutenberg' ),
function () use ( $option_name ) {
$option_value = get_option( $option_name );
if ( wp_is_collaboration_allowed() ) :
?>
<label for="wp_collaboration_enabled">
<input name="wp_collaboration_enabled" type="checkbox" id="wp_collaboration_enabled" value="1" <?php checked( '1', $option_value ); ?>/>
<?php _e( "Enable early access to real-time collaboration. Real-time collaboration may affect your website's performance.", 'gutenberg' ); ?>
</label>
<?php else : ?>
<div class="notice notice-warning inline">
<?php
printf(
/* translators: %s: Prefix "Note:". */
'<p>' . __( '%s Real-time collaboration has been disabled.', 'gutenberg' ) . '</p>',
'<strong>' . __( 'Note:', 'gutenberg' ) . '</strong>'
);
?>
</div>
<?php
endif;
},
'writing'
);
}
add_action( 'admin_init', 'gutenberg_register_real_time_collaboration_setting' );
}
if ( ! function_exists( 'wp_is_collaboration_enabled' ) ) {
/**
* Determines whether real-time collaboration is enabled.
*
* If the WP_ALLOW_COLLABORATION constant is false,
* collaboration is always disabled regardless of the database option.
* Otherwise, falls back to the 'wp_collaboration_enabled' option.
*
* @since 7.0.0
*
* @return bool Whether real-time collaboration is enabled.
*/
function wp_is_collaboration_enabled() {
return ( wp_is_collaboration_allowed() && (bool) get_option( 'wp_collaboration_enabled' ) );
}
}
if ( ! function_exists( 'wp_is_collaboration_allowed' ) ) {
/**
* Determines whether real-time collaboration is allowed.
*
* If the WP_ALLOW_COLLABORATION constant is false,
* collaboration is not allowed and cannot be enabled.
* The constant defaults to true, unless the WP_ALLOW_COLLABORATION
* environment variable is set to string "false".
*
* @since 7.0.0
*
* @return bool Whether real-time collaboration is allowed.
*/
function wp_is_collaboration_allowed() {
if ( ! defined( 'WP_ALLOW_COLLABORATION' ) ) {
$env_value = getenv( 'WP_ALLOW_COLLABORATION' );
if ( false === $env_value ) {
// Environment variable is not defined, default to allowing collaboration.
define( 'WP_ALLOW_COLLABORATION', true );
} else {
/*
* Environment variable is defined, let's confirm it is actually set to
* "true" as it may still have a string value "false" – the preceeding
* `if` branch only tests for the boolean `false`.
*/
define( 'WP_ALLOW_COLLABORATION', 'true' === $env_value );
}
}
return WP_ALLOW_COLLABORATION;
}
}
/**
* Injects the real-time collaboration setting into a global variable.
*
* @global string $pagenow The filename of the current screen.
*/
function gutenberg_inject_real_time_collaboration_setting() {
global $pagenow;
if ( ! wp_is_collaboration_enabled() ) {
return;
}
// Disable real-time collaboration on the site editor.
$enabled = true;
if (
'site-editor.php' === $pagenow ||
( 'admin.php' === $pagenow && isset( $_GET['page'] ) && 'site-editor-v2' === $_GET['page'] )
) {
$enabled = false;
}
wp_add_inline_script(
'wp-core-data',
'window._wpCollaborationEnabled = ' . wp_json_encode( $enabled ) . ';',
'after'
);
}
add_action( 'admin_init', 'gutenberg_inject_real_time_collaboration_setting' );
/**
* Core adds an option with the default value, so we need to set the option to
* our intended default when the Gutenberg plugin is activated, provided
* collaboration is allowed.
*/
function gutenberg_set_collaboration_option_on_activation() {
if ( wp_is_collaboration_allowed() ) {
update_option( 'wp_collaboration_enabled', '1' );
}
}
add_action( 'activate_gutenberg/gutenberg.php', 'gutenberg_set_collaboration_option_on_activation' );
/**
* Modifies the post list UI and heartbeat responses for real-time collaboration.
*
* When RTC is enabled, hides the lock icon and user avatar, replaces the
* user-specific lock text with "Currently being edited", changes the "Edit"
* row action to "Join", and re-enables controls that core normally hides
* for locked posts (since collaborative editing is possible).
*
* @global string $pagenow The filename of the current screen.
*/
function gutenberg_post_list_collaboration_ui() {
global $pagenow;
if ( ! wp_is_collaboration_enabled() ) {
return;
}
// Heartbeat filter applies globally (not just edit.php) since the
// heartbeat API can fire from any admin page.
add_filter( 'heartbeat_received', 'gutenberg_filter_locked_posts_heartbeat_for_rtc', 20 );
// CSS, JS, and row action overrides only apply on the posts list page.
if ( 'edit.php' !== $pagenow ) {
return;
}
add_action( 'admin_head', 'gutenberg_post_list_collaboration_styles' );
add_filter( 'gettext', 'gutenberg_filter_locked_post_text_for_rtc', 10, 3 );
add_filter( 'post_row_actions', 'gutenberg_post_list_collaboration_row_actions', 10, 2 );
add_filter( 'page_row_actions', 'gutenberg_post_list_collaboration_row_actions', 10, 2 );
}
add_action( 'admin_init', 'gutenberg_post_list_collaboration_ui' );
/**
* Filters the heartbeat response to remove user-specific lock information
* when real-time collaboration is enabled.
*
* WordPress core's wp_check_locked_posts() runs at priority 10 and populates
* the 'wp-check-locked-posts' key with user name, avatar, and text. This
* filter runs at priority 20 to replace that data with a generic message,
* preventing user-specific lock info from reaching the client.
*
* @param array $response The heartbeat response.
* @return array Modified heartbeat response.
*/
function gutenberg_filter_locked_posts_heartbeat_for_rtc( $response ) {
if ( ! empty( $response['wp-check-locked-posts'] ) ) {
foreach ( $response['wp-check-locked-posts'] as $key => $lock_data ) {
$response['wp-check-locked-posts'][ $key ]['text'] = __( 'Currently being edited', 'gutenberg' );
unset( $response['wp-check-locked-posts'][ $key ]['avatar_src'] );
unset( $response['wp-check-locked-posts'][ $key ]['avatar_src_2x'] );
}
}
return $response;
}
/**
* Outputs CSS to hide the post lock icon and user avatar in the post list
* when real-time collaboration is enabled.
*
* Also re-enables checkboxes and row actions that WordPress core hides for
* locked posts, since collaborative editing means the post is not exclusively
* locked. Toggles "Edit" / "Join" action link text via the
* `.wp-collaborative-editing` class that the heartbeat already manages.
*/
function gutenberg_post_list_collaboration_styles() {
?>
<style type="text/css">
/*
* Hide the lock indicator icon in the checkbox column.
* WordPress core shows it via .wp-locked .locked-indicator { display: block },
* so we match that specificity to override it.
*/
.wp-locked .locked-indicator {
display: none;
}
/* Hide the user avatar in the locked info area. */
.wp-locked .locked-info .locked-avatar {
display: none;
}
/*
* Re-enable controls that core hides for locked posts,
* since RTC allows collaborative editing.
* Must use `tr.wp-locked` to match core's specificity in
* list-tables.css and actually override its `display: none`.
*/
tr.wp-locked .check-column label,
tr.wp-locked .check-column input[type="checkbox"] {
display: revert;
}
tr.wp-locked .row-actions .inline {
display: revert;
}
/*
* Toggle "Edit" / "Join" action link text based on lock state.
* The heartbeat adds/removes .wp-locked on locked rows. This
* CSS only runs when RTC is enabled, so .wp-locked here always
* means collaborative editing, not exclusive locking.
*/
.join-action-text {
display: none;
}
.wp-locked .edit-action-text {
display: none;
}
.wp-locked .join-action-text {
display: inline;
}
</style>
<?php
}
/**
* Filters the translation of the lock text to replace user-specific
* "%s is currently editing" with a generic "Currently being edited"
* message on initial page render.
*
* WordPress core outputs this text server-side in WP_Posts_List_Table.
* Using a gettext filter replaces it before it reaches the browser,
* avoiding a flash of the original text.
*
* @param string $translation Translated text.
* @param string $text Original text to translate.
* @param string $domain Text domain.
* @return string Modified translation.
*/
function gutenberg_filter_locked_post_text_for_rtc( $translation, $text, $domain ) {
if ( 'default' === $domain && '%s is currently editing' === $text ) {
return __( 'Currently being edited', 'gutenberg' );
}
return $translation;
}
/**
* Filters post row actions to render both "Edit" and "Join" link text
* when real-time collaboration is enabled.
*
* Both labels are always present in the markup; CSS toggles visibility
* based on the `.wp-collaborative-editing` class the heartbeat manages.
* This ensures the link text updates when the lock state changes without
* requiring a page reload.
*
* @param string[] $actions An array of row action links.
* @param WP_Post $post The post object.
* @return string[] Modified row action links.
*/
function gutenberg_post_list_collaboration_row_actions( $actions, $post ) {
if ( ! isset( $actions['edit'] ) ) {
return $actions;
}
$title = _draft_or_post_title( $post->ID );
/*
* Both "Edit" and "Join" labels are rendered. The visible label is
* toggled by CSS based on the row's `wp-collaborative-editing` class,
* which is added or removed by inline-edit-post.js in response to
* heartbeat ticks.
*/
$actions['edit'] = sprintf(
'<a href="%1$s">'
. '<span class="edit-action-text">'
. '<span aria-hidden="true">%2$s</span>'
. '<span class="screen-reader-text">%3$s</span>'
. '</span>'
. '<span class="join-action-text">'
. '<span aria-hidden="true">%4$s</span>'
. '<span class="screen-reader-text">%5$s</span>'
. '</span>'
. '</a>',
get_edit_post_link( $post->ID ),
__( 'Edit' ),
/* translators: %s: Post title. */
sprintf( __( 'Edit “%s”' ), $title ),
/* translators: Action link text for a singular post in the post list. Can be any type of post. */
_x( 'Join', 'post list', 'gutenberg' ),
/* translators: %s: Post title. */
sprintf( __( 'Join editing “%s”', 'gutenberg' ), $title )
);
return $actions;
}