Real-time collaboration: add new REST endpoints, setting, and registered post meta.
Syncs/merges the PHP changes from the Gutenberg PR https://github.com/WordPress/gutenberg/pull/75366. In Gutenberg, we have added support for real-time collaboration using CRDT documents (via the [Yjs library](https://yjs.dev/)). This work has suggested the following additions to WordPress: 1. A default "sync provider" based on HTTP polling that allows collaborators to share updates with each other. Previously, we relied on WebRTC connections between collaborators for this purpose, but it proved unreliable under many network conditions. - Our solution is designed to work on any WordPress installation. - HTTP polling is the transport we identified as most likely to work universally. - Given the isolation and lifecycle of PHP processes, updates must be stored centrally in order to be shared among peers. We have chosen to store updates in post meta against a special post type, but alternate storage mechanisms are possible. - Collaborative editing can involve syncing multiple CRDT documents. To limit the number of connections consumed by this provider, requests are batched. - To prevent unbounded linear growth, updates are periodically compacted. - To avoid excessive load on lower-resourced hosts, this provider will benefit from usage limits (e.g., a maximum of three connected collaborators) enforced by the client (Gutenberg). 2. A new registered post meta that allows Gutenberg to persist CRDT documents alongside posts. - This provides all collaborators with a "shared starting point" for the collaborative session, which avoids duplicate updates. - Content stored in the WordPress database always remains the source of truth. If the content differs from the persisted CRDT document, the CRDT document is updated to match the database. 3. A new Writing setting that allows users to opt-in to real-time collaboration. - Enabling real-time collaboration disables post lock functionality and connects users to the sync provider. 4. A behavior change to autosaves is needed. When the the original author is editing a draft post (post_status == 'draft' OR 'auto-draft') and they hold the post lock, the autosave targets the actual post instead of an autosave revision. This puts the post data and the persisted CRDT document out of sync and leads to duplicate updates. When real-time collaboration is enabled, all collaborators must autosave in the same way. This PR provides a proposed implementation of the changes above. This corresponding Gutenberg PR moves the work from the `experimental` directory to `lib/compat`: https://github.com/WordPress/gutenberg/pull/75366 Cumulative work to add this functionality can be found using this label: https://github.com/WordPress/gutenberg/issues?q=label%3A%22%5BFeature%5D%20Real-time%20Collaboration%22%20is%3Apr Developed https://github.com/WordPress/wordpress-develop/pull/10894. Props czarate, paulkevan, ellatrix, timothyblynjacobs, westonruter, jorgefilipecosta, mindctrl. Fixes #64622. Built from https://develop.svn.wordpress.org/trunk@61689 git-svn-id: http://core.svn.wordpress.org/trunk@60997 1a063a9b-81f0-0310-95a4-ce76da25c4cd
This commit is contained in:
@@ -563,6 +563,9 @@ function populate_options( array $options = array() ) {
|
||||
|
||||
// 6.9.0
|
||||
'wp_notes_notify' => 1,
|
||||
|
||||
// 7.0.0
|
||||
'enable_real_time_collaboration' => 1,
|
||||
);
|
||||
|
||||
// 3.3.0
|
||||
|
||||
@@ -109,6 +109,13 @@ unset( $post_formats['standard'] );
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="enable_real_time_collaboration"><?php _e( 'Collaboration' ); ?></label></th>
|
||||
<td>
|
||||
<input name="enable_real_time_collaboration" type="checkbox" id="enable_real_time_collaboration" value="1" <?php checked( '1', get_option( 'enable_real_time_collaboration' ) ); ?> />
|
||||
<label for="enable_real_time_collaboration"><?php _e( 'Enable real-time collaboration' ); ?></label>
|
||||
</td>
|
||||
</tr>
|
||||
<?php
|
||||
if ( get_option( 'link_manager_enabled' ) ) :
|
||||
?>
|
||||
|
||||
@@ -153,6 +153,7 @@ $allowed_options = array(
|
||||
'default_email_category',
|
||||
'default_link_category',
|
||||
'default_post_format',
|
||||
'enable_real_time_collaboration',
|
||||
),
|
||||
);
|
||||
$allowed_options['misc'] = array();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
##
|
||||
## Bundle of CA Root Certificates
|
||||
##
|
||||
## Certificate data from Mozilla as of: Tue Dec 2 04:12:02 2025 GMT
|
||||
## Certificate data from Mozilla as of: Tue Nov 4 04:12:02 2025 GMT
|
||||
##
|
||||
## Find updated versions here: https://curl.se/docs/caextract.html
|
||||
##
|
||||
@@ -15,8 +15,8 @@
|
||||
## an Apache+mod_ssl webserver for SSL client authentication.
|
||||
## Just configure this file as the SSLCACertificateFile.
|
||||
##
|
||||
## Conversion done with mk-ca-bundle.pl version 1.30.
|
||||
## SHA256: a903b3cd05231e39332515ef7ebe37e697262f39515a52015c23c62805b73cd0
|
||||
## Conversion done with mk-ca-bundle.pl version 1.29.
|
||||
## SHA256: 039132bff5179ce57cec5803ba59fe37abe6d0297aeb538c5af27847f0702517
|
||||
##
|
||||
|
||||
|
||||
@@ -3167,6 +3167,96 @@ bbd+NvBNEU/zy4k6LHiRUKNbwMp1JvK/kF0LgoxgKJ/GcJpo5PECMFxYDlZ2z1jD1xCMuo6u47xk
|
||||
dUfFVZDj/bpV6wfEU6s3qe4hsiFbYI89MvHVI5TWWA==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
CommScope Public Trust ECC Root-01
|
||||
==================================
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICHTCCAaOgAwIBAgIUQ3CCd89NXTTxyq4yLzf39H91oJ4wCgYIKoZIzj0EAwMwTjELMAkGA1UE
|
||||
BhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBUcnVz
|
||||
dCBFQ0MgUm9vdC0wMTAeFw0yMTA0MjgxNzM1NDNaFw00NjA0MjgxNzM1NDJaME4xCzAJBgNVBAYT
|
||||
AlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1c3Qg
|
||||
RUNDIFJvb3QtMDEwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARLNumuV16ocNfQj3Rid8NeeqrltqLx
|
||||
eP0CflfdkXmcbLlSiFS8LwS+uM32ENEp7LXQoMPwiXAZu1FlxUOcw5tjnSCDPgYLpkJEhRGnSjot
|
||||
6dZoL0hOUysHP029uax3OVejQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G
|
||||
A1UdDgQWBBSOB2LAUN3GGQYARnQE9/OufXVNMDAKBggqhkjOPQQDAwNoADBlAjEAnDPfQeMjqEI2
|
||||
Jpc1XHvr20v4qotzVRVcrHgpD7oh2MSg2NED3W3ROT3Ek2DS43KyAjB8xX6I01D1HiXo+k515liW
|
||||
pDVfG2XqYZpwI7UNo5uSUm9poIyNStDuiw7LR47QjRE=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
CommScope Public Trust ECC Root-02
|
||||
==================================
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICHDCCAaOgAwIBAgIUKP2ZYEFHpgE6yhR7H+/5aAiDXX0wCgYIKoZIzj0EAwMwTjELMAkGA1UE
|
||||
BhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBUcnVz
|
||||
dCBFQ0MgUm9vdC0wMjAeFw0yMTA0MjgxNzQ0NTRaFw00NjA0MjgxNzQ0NTNaME4xCzAJBgNVBAYT
|
||||
AlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1c3Qg
|
||||
RUNDIFJvb3QtMDIwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAR4MIHoYx7l63FRD/cHB8o5mXxO1Q/M
|
||||
MDALj2aTPs+9xYa9+bG3tD60B8jzljHz7aRP+KNOjSkVWLjVb3/ubCK1sK9IRQq9qEmUv4RDsNuE
|
||||
SgMjGWdqb8FuvAY5N9GIIvejQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G
|
||||
A1UdDgQWBBTmGHX/72DehKT1RsfeSlXjMjZ59TAKBggqhkjOPQQDAwNnADBkAjAmc0l6tqvmSfR9
|
||||
Uj/UQQSugEODZXW5hYA4O9Zv5JOGq4/nich/m35rChJVYaoR4HkCMHfoMXGsPHED1oQmHhS48zs7
|
||||
3u1Z/GtMMH9ZzkXpc2AVmkzw5l4lIhVtwodZ0LKOag==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
CommScope Public Trust RSA Root-01
|
||||
==================================
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
6sRxJKi6KfkIsidWNTJf6jn7MZrVGczw
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
CommScope Public Trust RSA Root-02
|
||||
==================================
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
lM47ni3niAIi9G7oyOzWPPO5std3eqx7
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
Telekom Security TLS ECC Root 2020
|
||||
==================================
|
||||
-----BEGIN CERTIFICATE-----
|
||||
|
||||
24
wp-includes/collaboration.php
Normal file
24
wp-includes/collaboration.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
/**
|
||||
* Bootstraps collaborative editing.
|
||||
*
|
||||
* @package WordPress
|
||||
* @since 7.0.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Injects the real-time collaboration setting into a global variable.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @access private
|
||||
*/
|
||||
function wp_collaboration_inject_setting() {
|
||||
if ( get_option( 'enable_real_time_collaboration' ) ) {
|
||||
wp_add_inline_script(
|
||||
'wp-core-data',
|
||||
'window._wpCollaborationEnabled = true;',
|
||||
'after'
|
||||
);
|
||||
}
|
||||
}
|
||||
505
wp-includes/collaboration/class-wp-http-polling-sync-server.php
Normal file
505
wp-includes/collaboration/class-wp-http-polling-sync-server.php
Normal file
@@ -0,0 +1,505 @@
|
||||
<?php
|
||||
/**
|
||||
* WP_HTTP_Polling_Sync_Server class
|
||||
*
|
||||
* @package WordPress
|
||||
*/
|
||||
|
||||
/**
|
||||
* Core class that contains an HTTP server used for collaborative editing.
|
||||
*
|
||||
* @since 7.0.0
|
||||
* @access private
|
||||
*/
|
||||
class WP_HTTP_Polling_Sync_Server {
|
||||
/**
|
||||
* REST API namespace.
|
||||
*
|
||||
* @since 7.0.0
|
||||
* @var string
|
||||
*/
|
||||
const REST_NAMESPACE = 'wp-sync/v1';
|
||||
|
||||
/**
|
||||
* Awareness timeout in seconds. Clients that haven't updated
|
||||
* their awareness state within this time are considered disconnected.
|
||||
*
|
||||
* @since 7.0.0
|
||||
* @var int
|
||||
*/
|
||||
const AWARENESS_TIMEOUT = 30;
|
||||
|
||||
/**
|
||||
* Threshold used to signal clients to send a compaction update.
|
||||
*
|
||||
* @since 7.0.0
|
||||
* @var int
|
||||
*/
|
||||
const COMPACTION_THRESHOLD = 50;
|
||||
|
||||
/**
|
||||
* Sync update type: compaction.
|
||||
*
|
||||
* @since 7.0.0
|
||||
* @var string
|
||||
*/
|
||||
const UPDATE_TYPE_COMPACTION = 'compaction';
|
||||
|
||||
/**
|
||||
* Sync update type: sync step 1.
|
||||
*
|
||||
* @since 7.0.0
|
||||
* @var string
|
||||
*/
|
||||
const UPDATE_TYPE_SYNC_STEP1 = 'sync_step1';
|
||||
|
||||
/**
|
||||
* Sync update type: sync step 2.
|
||||
*
|
||||
* @since 7.0.0
|
||||
* @var string
|
||||
*/
|
||||
const UPDATE_TYPE_SYNC_STEP2 = 'sync_step2';
|
||||
|
||||
/**
|
||||
* Sync update type: regular update.
|
||||
*
|
||||
* @since 7.0.0
|
||||
* @var string
|
||||
*/
|
||||
const UPDATE_TYPE_UPDATE = 'update';
|
||||
|
||||
/**
|
||||
* Storage backend for sync updates.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*/
|
||||
private WP_Sync_Storage $storage;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param WP_Sync_Storage $storage Storage backend for sync updates.
|
||||
*/
|
||||
public function __construct( WP_Sync_Storage $storage ) {
|
||||
$this->storage = $storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers REST API routes.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*/
|
||||
public function register_routes(): void {
|
||||
$typed_update_args = array(
|
||||
'properties' => array(
|
||||
'data' => array(
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
),
|
||||
'type' => array(
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'enum' => array(
|
||||
self::UPDATE_TYPE_COMPACTION,
|
||||
self::UPDATE_TYPE_SYNC_STEP1,
|
||||
self::UPDATE_TYPE_SYNC_STEP2,
|
||||
self::UPDATE_TYPE_UPDATE,
|
||||
),
|
||||
),
|
||||
),
|
||||
'required' => true,
|
||||
'type' => 'object',
|
||||
);
|
||||
|
||||
$room_args = array(
|
||||
'after' => array(
|
||||
'minimum' => 0,
|
||||
'required' => true,
|
||||
'type' => 'integer',
|
||||
),
|
||||
'awareness' => array(
|
||||
'required' => true,
|
||||
'type' => 'object',
|
||||
),
|
||||
'client_id' => array(
|
||||
'minimum' => 1,
|
||||
'required' => true,
|
||||
'type' => 'integer',
|
||||
),
|
||||
'room' => array(
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$',
|
||||
),
|
||||
'updates' => array(
|
||||
'items' => $typed_update_args,
|
||||
'minItems' => 0,
|
||||
'required' => true,
|
||||
'type' => 'array',
|
||||
),
|
||||
);
|
||||
|
||||
register_rest_route(
|
||||
self::REST_NAMESPACE,
|
||||
'/updates',
|
||||
array(
|
||||
'methods' => array( WP_REST_Server::CREATABLE ),
|
||||
'callback' => array( $this, 'handle_request' ),
|
||||
'permission_callback' => array( $this, 'check_permissions' ),
|
||||
'args' => array(
|
||||
'rooms' => array(
|
||||
'items' => array(
|
||||
'properties' => $room_args,
|
||||
'type' => 'object',
|
||||
),
|
||||
'required' => true,
|
||||
'type' => 'array',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current user has permission to access a room.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param WP_REST_Request $request The REST request.
|
||||
* @return bool|WP_Error True if user has permission, otherwise WP_Error with details.
|
||||
*/
|
||||
public function check_permissions( WP_REST_Request $request ) {
|
||||
// Minimum cap check. Is user logged in with a contributor role or higher?
|
||||
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||
return new WP_Error(
|
||||
'rest_cannot_edit',
|
||||
__( 'You do not have permission to perform this action' ),
|
||||
array( 'status' => rest_authorization_required_code() )
|
||||
);
|
||||
}
|
||||
|
||||
$rooms = $request['rooms'];
|
||||
|
||||
foreach ( $rooms as $room ) {
|
||||
$room = $room['room'];
|
||||
$type_parts = explode( '/', $room, 2 );
|
||||
$object_parts = explode( ':', $type_parts[1] ?? '', 2 );
|
||||
|
||||
$entity_kind = $type_parts[0];
|
||||
$entity_name = $object_parts[0];
|
||||
$object_id = $object_parts[1] ?? null;
|
||||
|
||||
if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) {
|
||||
return new WP_Error(
|
||||
'rest_cannot_edit',
|
||||
sprintf(
|
||||
/* translators: %s: The room name encodes the current entity being synced. */
|
||||
__( 'You do not have permission to sync this entity: %s.' ),
|
||||
$room
|
||||
),
|
||||
array( 'status' => rest_authorization_required_code() )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles request: stores sync updates and awareness data, and returns
|
||||
* updates the client is missing.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param WP_REST_Request $request The REST request.
|
||||
* @return WP_REST_Response|WP_Error Response object or error.
|
||||
*/
|
||||
public function handle_request( WP_REST_Request $request ) {
|
||||
$rooms = $request['rooms'];
|
||||
$response = array(
|
||||
'rooms' => array(),
|
||||
);
|
||||
|
||||
foreach ( $rooms as $room_request ) {
|
||||
$awareness = $room_request['awareness'];
|
||||
$client_id = $room_request['client_id'];
|
||||
$cursor = $room_request['after'];
|
||||
$room = $room_request['room'];
|
||||
|
||||
// Merge awareness state.
|
||||
$merged_awareness = $this->process_awareness_update( $room, $client_id, $awareness );
|
||||
|
||||
// The lowest client ID is nominated to perform compaction when needed.
|
||||
$is_compactor = false;
|
||||
if ( count( $merged_awareness ) > 0 ) {
|
||||
$is_compactor = min( array_keys( $merged_awareness ) ) === $client_id;
|
||||
}
|
||||
|
||||
// Process each update according to its type.
|
||||
foreach ( $room_request['updates'] as $update ) {
|
||||
$result = $this->process_sync_update( $room, $client_id, $cursor, $update );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
// Get updates for this client.
|
||||
$room_response = $this->get_updates( $room, $client_id, $cursor, $is_compactor );
|
||||
$room_response['awareness'] = $merged_awareness;
|
||||
|
||||
$response['rooms'][] = $room_response;
|
||||
}
|
||||
|
||||
return new WP_REST_Response( $response, 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current user can sync a specific entity type.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $entity_kind The entity kind, e.g. 'postType', 'taxonomy', 'root'.
|
||||
* @param string $entity_name The entity name, e.g. 'post', 'category', 'site'.
|
||||
* @param string|null $object_id The object ID / entity key for single entities, null for collections.
|
||||
* @return bool True if user has permission, otherwise false.
|
||||
*/
|
||||
private function can_user_sync_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool {
|
||||
// Handle single post type entities with a defined object ID.
|
||||
if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) {
|
||||
return current_user_can( 'edit_post', (int) $object_id );
|
||||
}
|
||||
|
||||
// Handle single taxonomy term entities with a defined object ID.
|
||||
if ( 'taxonomy' === $entity_kind && is_numeric( $object_id ) ) {
|
||||
$taxonomy = get_taxonomy( $entity_name );
|
||||
return isset( $taxonomy->cap->assign_terms ) && current_user_can( $taxonomy->cap->assign_terms );
|
||||
}
|
||||
|
||||
// All the remaining checks are for collections. If an object ID is provided,
|
||||
// reject the request.
|
||||
if ( null !== $object_id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For postType collections, check if the user can edit posts of this type.
|
||||
if ( 'postType' === $entity_kind ) {
|
||||
$post_type_object = get_post_type_object( $entity_name );
|
||||
if ( ! isset( $post_type_object->cap->edit_posts ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return current_user_can( $post_type_object->cap->edit_posts );
|
||||
}
|
||||
|
||||
// Collection syncing does not exchange entity data. It only signals if
|
||||
// another user has updated an entity in the collection. Therefore, we only
|
||||
// compare against an allow list of collection types.
|
||||
$allowed_collection_entity_kinds = array(
|
||||
'postType',
|
||||
'root',
|
||||
'taxonomy',
|
||||
);
|
||||
|
||||
return in_array( $entity_kind, $allowed_collection_entity_kinds, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes and stores an awareness update from a client.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @param int $client_id Client identifier.
|
||||
* @param array<string, mixed>|null $awareness_update Awareness state sent by the client.
|
||||
* @return array<int, array<string, mixed>> Map of client ID to awareness state.
|
||||
*/
|
||||
private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array {
|
||||
$existing_awareness = $this->storage->get_awareness_state( $room );
|
||||
$updated_awareness = array();
|
||||
$current_time = time();
|
||||
|
||||
foreach ( $existing_awareness as $entry ) {
|
||||
// Remove this client's entry (it will be updated below).
|
||||
if ( $client_id === $entry['client_id'] ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove entries that have expired.
|
||||
if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$updated_awareness[] = $entry;
|
||||
}
|
||||
|
||||
// Add this client's awareness state.
|
||||
if ( null !== $awareness_update ) {
|
||||
$updated_awareness[] = array(
|
||||
'client_id' => $client_id,
|
||||
'state' => $awareness_update,
|
||||
'updated_at' => $current_time,
|
||||
);
|
||||
}
|
||||
|
||||
// This action can fail, but it shouldn't fail the entire request.
|
||||
$this->storage->set_awareness_state( $room, $updated_awareness );
|
||||
|
||||
// Convert to client_id => state map for response.
|
||||
$response = array();
|
||||
foreach ( $updated_awareness as $entry ) {
|
||||
$response[ $entry['client_id'] ] = $entry['state'];
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a sync update based on its type.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @param int $client_id Client identifier.
|
||||
* @param int $cursor Client cursor (marker of last seen update).
|
||||
* @param array{data: string, type: string} $update Sync update.
|
||||
* @return true|WP_Error True on success, WP_Error on storage failure.
|
||||
*/
|
||||
private function process_sync_update( string $room, int $client_id, int $cursor, array $update ) {
|
||||
$data = $update['data'];
|
||||
$type = $update['type'];
|
||||
|
||||
switch ( $type ) {
|
||||
case self::UPDATE_TYPE_COMPACTION:
|
||||
/*
|
||||
* Compaction replaces updates the client has already seen. Only remove
|
||||
* updates with markers before the client's cursor to preserve updates
|
||||
* that arrived since the client's last sync.
|
||||
*
|
||||
* Check for a newer compaction update first. If one exists, skip this
|
||||
* compaction to avoid overwriting it.
|
||||
*/
|
||||
$updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor );
|
||||
$has_newer_compaction = false;
|
||||
|
||||
foreach ( $updates_after_cursor as $existing ) {
|
||||
if ( self::UPDATE_TYPE_COMPACTION === $existing['type'] ) {
|
||||
$has_newer_compaction = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $has_newer_compaction ) {
|
||||
if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) {
|
||||
return new WP_Error(
|
||||
'rest_sync_storage_error',
|
||||
__( 'Failed to remove updates during compaction.' ),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return $this->add_update( $room, $client_id, $type, $data );
|
||||
}
|
||||
break;
|
||||
|
||||
case self::UPDATE_TYPE_SYNC_STEP1:
|
||||
case self::UPDATE_TYPE_SYNC_STEP2:
|
||||
case self::UPDATE_TYPE_UPDATE:
|
||||
/*
|
||||
* Sync step 1 announces a client's state vector. Other clients need
|
||||
* to see it so they can respond with sync_step2 containing missing
|
||||
* updates. The cursor-based filtering prevents re-delivery.
|
||||
*
|
||||
* Sync step 2 contains updates for a specific client.
|
||||
*
|
||||
* All updates are stored persistently.
|
||||
*/
|
||||
return $this->add_update( $room, $client_id, $type, $data );
|
||||
}
|
||||
|
||||
return new WP_Error(
|
||||
'rest_invalid_update_type',
|
||||
__( 'Invalid sync update type.' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an update to a room's update list via storage.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @param int $client_id Client identifier.
|
||||
* @param string $type Update type (sync_step1, sync_step2, update, compaction).
|
||||
* @param string $data Base64-encoded update data.
|
||||
* @return true|WP_Error True on success, WP_Error on storage failure.
|
||||
*/
|
||||
private function add_update( string $room, int $client_id, string $type, string $data ) {
|
||||
$update = array(
|
||||
'client_id' => $client_id,
|
||||
'data' => $data,
|
||||
'type' => $type,
|
||||
);
|
||||
|
||||
if ( ! $this->storage->add_update( $room, $update ) ) {
|
||||
return new WP_Error(
|
||||
'rest_sync_storage_error',
|
||||
__( 'Failed to store sync update.' ),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets sync updates for a specific client from a room after a given cursor.
|
||||
*
|
||||
* Delegates cursor-based retrieval to the storage layer, then applies
|
||||
* client-specific filtering and compaction logic.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @param int $client_id Client identifier.
|
||||
* @param int $cursor Return updates after this cursor.
|
||||
* @param bool $is_compactor True if this client is nominated to perform compaction.
|
||||
* @return array{
|
||||
* end_cursor: int,
|
||||
* should_compact: bool,
|
||||
* room: string,
|
||||
* total_updates: int,
|
||||
* updates: array<int, array{data: string, type: string}>,
|
||||
* } Response data for this room.
|
||||
*/
|
||||
private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor ): array {
|
||||
$updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor );
|
||||
$total_updates = $this->storage->get_update_count( $room );
|
||||
|
||||
// Filter out this client's updates, except compaction updates.
|
||||
$typed_updates = array();
|
||||
foreach ( $updates_after_cursor as $update ) {
|
||||
if ( $client_id === $update['client_id'] && self::UPDATE_TYPE_COMPACTION !== $update['type'] ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$typed_updates[] = array(
|
||||
'data' => $update['data'],
|
||||
'type' => $update['type'],
|
||||
);
|
||||
}
|
||||
|
||||
$should_compact = $is_compactor && $total_updates > self::COMPACTION_THRESHOLD;
|
||||
|
||||
return array(
|
||||
'end_cursor' => $this->storage->get_cursor( $room ),
|
||||
'room' => $room,
|
||||
'should_compact' => $should_compact,
|
||||
'total_updates' => $total_updates,
|
||||
'updates' => $typed_updates,
|
||||
);
|
||||
}
|
||||
}
|
||||
322
wp-includes/collaboration/class-wp-sync-post-meta-storage.php
Normal file
322
wp-includes/collaboration/class-wp-sync-post-meta-storage.php
Normal file
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
/**
|
||||
* WP_Sync_Post_Meta_Storage class
|
||||
*
|
||||
* @package WordPress
|
||||
*/
|
||||
|
||||
/**
|
||||
* Core class that provides an interface for storing and retrieving sync
|
||||
* updates and awareness data during a collaborative session.
|
||||
*
|
||||
* Data is stored as post meta on a dedicated post per room of a custom post type.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @access private
|
||||
*/
|
||||
class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage {
|
||||
/**
|
||||
* Post type for sync storage.
|
||||
*
|
||||
* @since 7.0.0
|
||||
* @var string
|
||||
*/
|
||||
const POST_TYPE = 'wp_sync_storage';
|
||||
|
||||
/**
|
||||
* Meta key for awareness state.
|
||||
*
|
||||
* @since 7.0.0
|
||||
* @var string
|
||||
*/
|
||||
const AWARENESS_META_KEY = 'wp_sync_awareness';
|
||||
|
||||
/**
|
||||
* Meta key for sync updates.
|
||||
*
|
||||
* @since 7.0.0
|
||||
* @var string
|
||||
*/
|
||||
const SYNC_UPDATE_META_KEY = 'wp_sync_update';
|
||||
|
||||
/**
|
||||
* Cache of cursors by room.
|
||||
*
|
||||
* @since 7.0.0
|
||||
* @var array<string, int>
|
||||
*/
|
||||
private array $room_cursors = array();
|
||||
|
||||
/**
|
||||
* Cache of update counts by room.
|
||||
*
|
||||
* @since 7.0.0
|
||||
* @var array<string, int>
|
||||
*/
|
||||
private array $room_update_counts = array();
|
||||
|
||||
/**
|
||||
* Cache of storage post IDs by room hash.
|
||||
*
|
||||
* @since 7.0.0
|
||||
* @var array<string, int>
|
||||
*/
|
||||
private static array $storage_post_ids = array();
|
||||
|
||||
/**
|
||||
* Adds a sync update to a given room.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @param mixed $update Sync update.
|
||||
* @return bool True on success, false on failure.
|
||||
*/
|
||||
public function add_update( string $room, $update ): bool {
|
||||
$post_id = $this->get_storage_post_id( $room );
|
||||
if ( null === $post_id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create an envelope and stamp each update to enable cursor-based filtering.
|
||||
$envelope = array(
|
||||
'timestamp' => $this->get_time_marker(),
|
||||
'value' => $update,
|
||||
);
|
||||
|
||||
return (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all sync updates for a given room.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @return array<int, array{ timestamp: int, value: mixed }> Sync updates.
|
||||
*/
|
||||
private function get_all_updates( string $room ): array {
|
||||
$this->room_cursors[ $room ] = $this->get_time_marker() - 100; // Small buffer to ensure consistency.
|
||||
|
||||
$post_id = $this->get_storage_post_id( $room );
|
||||
if ( null === $post_id ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$updates = get_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, false );
|
||||
|
||||
if ( ! is_array( $updates ) ) {
|
||||
$updates = array();
|
||||
}
|
||||
|
||||
// Filter out any updates that don't have the expected structure.
|
||||
$updates = array_filter(
|
||||
$updates,
|
||||
static function ( $update ): bool {
|
||||
return is_array( $update ) && isset( $update['timestamp'], $update['value'] ) && is_int( $update['timestamp'] );
|
||||
}
|
||||
);
|
||||
|
||||
$this->room_update_counts[ $room ] = count( $updates );
|
||||
|
||||
return $updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets awareness state for a given room.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @return array<int, mixed> Awareness state.
|
||||
*/
|
||||
public function get_awareness_state( string $room ): array {
|
||||
$post_id = $this->get_storage_post_id( $room );
|
||||
if ( null === $post_id ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$awareness = get_post_meta( $post_id, self::AWARENESS_META_KEY, true );
|
||||
|
||||
if ( ! is_array( $awareness ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
return array_values( $awareness );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets awareness state for a given room.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @param array<int, mixed> $awareness Serializable awareness state.
|
||||
* @return bool True on success, false on failure.
|
||||
*/
|
||||
public function set_awareness_state( string $room, array $awareness ): bool {
|
||||
$post_id = $this->get_storage_post_id( $room );
|
||||
if ( null === $post_id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// update_post_meta returns false if the value is the same as the existing value.
|
||||
update_post_meta( $post_id, self::AWARENESS_META_KEY, $awareness );
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current cursor for a given room.
|
||||
*
|
||||
* The cursor is set during get_updates_after_cursor() and represents the
|
||||
* point in time just before the updates were retrieved, with a small buffer
|
||||
* to ensure consistency.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @return int Current cursor for the room.
|
||||
*/
|
||||
public function get_cursor( string $room ): int {
|
||||
return $this->room_cursors[ $room ] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates the storage post for a given room.
|
||||
*
|
||||
* Each room gets its own dedicated post so that post meta cache
|
||||
* invalidation is scoped to a single room rather than all of them.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @return int|null Post ID.
|
||||
*/
|
||||
private function get_storage_post_id( string $room ): ?int {
|
||||
$room_hash = md5( $room );
|
||||
|
||||
if ( isset( self::$storage_post_ids[ $room_hash ] ) ) {
|
||||
return self::$storage_post_ids[ $room_hash ];
|
||||
}
|
||||
|
||||
// Try to find an existing post for this room.
|
||||
$posts = get_posts(
|
||||
array(
|
||||
'post_type' => self::POST_TYPE,
|
||||
'posts_per_page' => 1,
|
||||
'post_status' => 'publish',
|
||||
'name' => $room_hash,
|
||||
'fields' => 'ids',
|
||||
)
|
||||
);
|
||||
|
||||
$post_id = array_first( $posts );
|
||||
if ( is_int( $post_id ) ) {
|
||||
self::$storage_post_ids[ $room_hash ] = $post_id;
|
||||
return $post_id;
|
||||
}
|
||||
|
||||
// Create new post for this room.
|
||||
$post_id = wp_insert_post(
|
||||
array(
|
||||
'post_type' => self::POST_TYPE,
|
||||
'post_status' => 'publish',
|
||||
'post_title' => 'Sync Storage',
|
||||
'post_name' => $room_hash,
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_int( $post_id ) ) {
|
||||
self::$storage_post_ids[ $room_hash ] = $post_id;
|
||||
return $post_id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current time in milliseconds as a comparable time marker.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @return int Current time in milliseconds.
|
||||
*/
|
||||
private function get_time_marker(): int {
|
||||
return (int) floor( microtime( true ) * 1000 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of updates stored for a given room.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @return int Number of updates stored for the room.
|
||||
*/
|
||||
public function get_update_count( string $room ): int {
|
||||
return $this->room_update_counts[ $room ] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves sync updates from a room for a given client and cursor. Updates
|
||||
* from the specified client should be excluded.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @param int $cursor Return updates after this cursor.
|
||||
* @return array<int, mixed> Sync updates.
|
||||
*/
|
||||
public function get_updates_after_cursor( string $room, int $cursor ): array {
|
||||
$all_updates = $this->get_all_updates( $room );
|
||||
$updates = array();
|
||||
|
||||
foreach ( $all_updates as $update ) {
|
||||
if ( $update['timestamp'] > $cursor ) {
|
||||
$updates[] = $update;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp to ensure order.
|
||||
usort(
|
||||
$updates,
|
||||
fn ( $a, $b ) => $a['timestamp'] <=> $b['timestamp']
|
||||
);
|
||||
|
||||
return wp_list_pluck( $updates, 'value' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes updates from a room that are older than the given cursor.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @param int $cursor Remove updates with markers < this cursor.
|
||||
* @return bool True on success, false on failure.
|
||||
*/
|
||||
public function remove_updates_before_cursor( string $room, int $cursor ): bool {
|
||||
$post_id = $this->get_storage_post_id( $room );
|
||||
if ( null === $post_id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$all_updates = $this->get_all_updates( $room );
|
||||
|
||||
// Remove all updates for the room and re-store only those that are newer than the cursor.
|
||||
if ( ! delete_post_meta( $post_id, self::SYNC_UPDATE_META_KEY ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-store envelopes directly to avoid double-wrapping by add_update().
|
||||
$add_result = true;
|
||||
foreach ( $all_updates as $envelope ) {
|
||||
if ( $add_result && $envelope['timestamp'] >= $cursor ) {
|
||||
$add_result = (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false );
|
||||
}
|
||||
}
|
||||
|
||||
return $add_result;
|
||||
}
|
||||
}
|
||||
86
wp-includes/collaboration/interface-wp-sync-storage.php
Normal file
86
wp-includes/collaboration/interface-wp-sync-storage.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
/**
|
||||
* WP_Sync_Storage interface
|
||||
*
|
||||
* @package WordPress
|
||||
*/
|
||||
|
||||
interface WP_Sync_Storage {
|
||||
/**
|
||||
* Adds a sync update to a given room.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @param mixed $update Serializable sync update, opaque to the storage implementation.
|
||||
* @return bool True on success, false on failure.
|
||||
*/
|
||||
public function add_update( string $room, $update ): bool;
|
||||
|
||||
/**
|
||||
* Gets awareness state for a given room.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @return array<int, mixed> Awareness state.
|
||||
*/
|
||||
public function get_awareness_state( string $room ): array;
|
||||
|
||||
/**
|
||||
* Gets the current cursor for a given room. This should return a monotonically
|
||||
* increasing integer that represents the last update that was returned for the
|
||||
* room during the current request. This allows clients to retrieve updates
|
||||
* after a specific cursor on subsequent requests.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @return int Current cursor for the room.
|
||||
*/
|
||||
public function get_cursor( string $room ): int;
|
||||
|
||||
/**
|
||||
* Gets the total number of stored updates for a given room.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @return int Total number of updates.
|
||||
*/
|
||||
public function get_update_count( string $room ): int;
|
||||
|
||||
/**
|
||||
* Retrieves sync updates from a room for a given client and cursor. Updates
|
||||
* from the specified client should be excluded.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @param int $cursor Return updates after this cursor.
|
||||
* @return array<int, mixed> Sync updates.
|
||||
*/
|
||||
public function get_updates_after_cursor( string $room, int $cursor ): array;
|
||||
|
||||
/**
|
||||
* Removes updates from a room that are older than the provided cursor.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @param int $cursor Remove updates with markers < this cursor.
|
||||
* @return bool True on success, false on failure.
|
||||
*/
|
||||
public function remove_updates_before_cursor( string $room, int $cursor ): bool;
|
||||
|
||||
/**
|
||||
* Sets awareness state for a given room.
|
||||
*
|
||||
* @since 7.0.0
|
||||
*
|
||||
* @param string $room Room identifier.
|
||||
* @param array<int, mixed> $awareness Serializable awareness state.
|
||||
* @return bool True on success, false on failure.
|
||||
*/
|
||||
public function set_awareness_state( string $room, array $awareness ): bool;
|
||||
}
|
||||
@@ -786,6 +786,9 @@ add_action( 'deleted_post', '_wp_after_delete_font_family', 10, 2 );
|
||||
add_action( 'before_delete_post', '_wp_before_delete_font_face', 10, 2 );
|
||||
add_action( 'init', '_wp_register_default_font_collections' );
|
||||
|
||||
// Collaboration.
|
||||
add_action( 'admin_init', 'wp_collaboration_inject_setting' );
|
||||
|
||||
// Add ignoredHookedBlocks metadata attribute to the template and template part post types.
|
||||
add_filter( 'rest_pre_insert_wp_template', 'inject_ignored_hooked_blocks_metadata_attributes' );
|
||||
add_filter( 'rest_pre_insert_wp_template_part', 'inject_ignored_hooked_blocks_metadata_attributes' );
|
||||
|
||||
@@ -2897,6 +2897,18 @@ function register_initial_settings() {
|
||||
)
|
||||
);
|
||||
|
||||
register_setting(
|
||||
'writing',
|
||||
'enable_real_time_collaboration',
|
||||
array(
|
||||
'type' => 'boolean',
|
||||
'description' => __( 'Enable Real-Time Collaboration' ),
|
||||
'sanitize_callback' => 'rest_sanitize_boolean',
|
||||
'default' => true,
|
||||
'show_in_rest' => true,
|
||||
)
|
||||
);
|
||||
|
||||
register_setting(
|
||||
'reading',
|
||||
'posts_per_page',
|
||||
|
||||
@@ -657,6 +657,41 @@ function create_initial_post_types() {
|
||||
)
|
||||
);
|
||||
|
||||
if ( get_option( 'enable_real_time_collaboration' ) ) {
|
||||
register_post_type(
|
||||
'wp_sync_storage',
|
||||
array(
|
||||
'labels' => array(
|
||||
'name' => __( 'Sync Updates' ),
|
||||
'singular_name' => __( 'Sync Update' ),
|
||||
),
|
||||
'public' => false,
|
||||
'_builtin' => true, /* internal use only. don't use this when registering your own post type. */
|
||||
'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' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
register_post_status(
|
||||
'publish',
|
||||
array(
|
||||
@@ -8611,6 +8646,7 @@ function use_block_editor_for_post_type( $post_type ) {
|
||||
* Registers any additional post meta fields.
|
||||
*
|
||||
* @since 6.3.0 Adds `wp_pattern_sync_status` meta field to the wp_block post type so an unsynced option can be added.
|
||||
* @since 7.0.0 Adds `_crdt_document` meta field to post types so that CRDT documents can be persisted.
|
||||
*
|
||||
* @link https://github.com/WordPress/gutenberg/pull/51144
|
||||
*/
|
||||
@@ -8630,4 +8666,30 @@ function wp_create_initial_post_meta() {
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
if ( get_option( 'enable_real_time_collaboration' ) ) {
|
||||
register_meta(
|
||||
'post',
|
||||
'_crdt_document',
|
||||
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',
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,6 +428,13 @@ function create_initial_rest_routes() {
|
||||
// Icons.
|
||||
$icons_controller = new WP_REST_Icons_Controller();
|
||||
$icons_controller->register_routes();
|
||||
|
||||
// Collaboration.
|
||||
if ( get_option( 'enable_real_time_collaboration' ) ) {
|
||||
$sync_storage = new WP_Sync_Post_Meta_Storage();
|
||||
$sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage );
|
||||
$sync_server->register_routes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -232,7 +232,31 @@ class WP_REST_Autosaves_Controller extends WP_REST_Revisions_Controller {
|
||||
$post_lock = wp_check_post_lock( $post->ID );
|
||||
$is_draft = 'draft' === $post->post_status || 'auto-draft' === $post->post_status;
|
||||
|
||||
if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock ) {
|
||||
/*
|
||||
* In the context of real-time collaboration, all peers are effectively
|
||||
* authors and we don't want to vary behavior based on whether they are the
|
||||
* original author. Always target an autosave revision.
|
||||
*
|
||||
* This avoids the following issue when real-time collaboration is enabled:
|
||||
*
|
||||
* - Autosaves from the original author (if they have the post lock) will
|
||||
* target the saved post.
|
||||
*
|
||||
* - Autosaves from other users are applied to a post revision.
|
||||
*
|
||||
* - If any user reloads a post, they load changes from the author's autosave.
|
||||
*
|
||||
* - The saved post has now diverged from the persisted CRDT document. The
|
||||
* content (and/or title or excerpt) are now "ahead" of the persisted CRDT
|
||||
* document.
|
||||
*
|
||||
* - When the persisted CRDT document is loaded, a diff is computed against
|
||||
* the saved post. This diff is then applied to the in-memory CRDT
|
||||
* document, which can lead to duplicate inserts or deletions.
|
||||
*/
|
||||
$is_collaboration_enabled = get_option( 'enable_real_time_collaboration' );
|
||||
|
||||
if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock && ! $is_collaboration_enabled ) {
|
||||
/*
|
||||
* Draft posts for the same author: autosaving updates the post and does not create a revision.
|
||||
* Convert the post object to an array and add slashes, wp_update_post() expects escaped array.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*
|
||||
* @global string $wp_version
|
||||
*/
|
||||
$wp_version = '7.0-alpha-61688';
|
||||
$wp_version = '7.0-alpha-61689';
|
||||
|
||||
/**
|
||||
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.
|
||||
|
||||
@@ -300,6 +300,10 @@ require ABSPATH . WPINC . '/abilities-api/class-wp-ability.php';
|
||||
require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php';
|
||||
require ABSPATH . WPINC . '/abilities-api.php';
|
||||
require ABSPATH . WPINC . '/abilities.php';
|
||||
require ABSPATH . WPINC . '/collaboration/interface-wp-sync-storage.php';
|
||||
require ABSPATH . WPINC . '/collaboration/class-wp-sync-post-meta-storage.php';
|
||||
require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-sync-server.php';
|
||||
require ABSPATH . WPINC . '/collaboration.php';
|
||||
require ABSPATH . WPINC . '/rest-api.php';
|
||||
require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php';
|
||||
require ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php';
|
||||
|
||||
Reference in New Issue
Block a user