Skip to content

Commit 36afbb3

Browse files
committed
Add support for syncing post meta
1 parent 2b902b5 commit 36afbb3

File tree

3 files changed

+204
-3
lines changed

3 files changed

+204
-3
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* WordPress dependencies
3+
*/
4+
import { applyFilters } from '@wordpress/hooks';
5+
6+
/**
7+
* Internal dependencies
8+
*/
9+
import { type Type } from '../entity-types';
10+
11+
const metaDecisionCache: Map< string, Map< string, boolean > > = new Map();
12+
13+
/**
14+
* Given a meta key and post type definition, return a decision on whether to
15+
* sync the meta property.
16+
*
17+
* @param {string} metaKey The meta key.
18+
* @param {Type} postType The post type definition.
19+
* @return {boolean} Whether to sync the meta property.
20+
*/
21+
export function shouldSyncMetaForPostType(
22+
metaKey: string,
23+
postType: Type
24+
): boolean {
25+
if ( ! metaDecisionCache.has( postType.slug ) ) {
26+
metaDecisionCache.set( postType.slug, new Map() );
27+
}
28+
29+
const decisionMap = metaDecisionCache.get( postType.slug )!;
30+
31+
if ( decisionMap.has( metaKey ) ) {
32+
return decisionMap.get( metaKey )!;
33+
}
34+
35+
/**
36+
* In order to be available to the sync module, meta properties must be
37+
* registered against the post type and made available via the REST API
38+
* (`'show_in_rest' => true`).
39+
*
40+
* Of the registered meta properties, by default we do not sync "hidden" meta
41+
* fields (leading underscore in the meta key). This filter allows third-party
42+
* code to override that behavior.
43+
*
44+
* @param {boolean} shouldSync Whether to sync the meta property.
45+
* @param {string} metaKey Meta key.
46+
* @param {string} postTypeSlug The post type slug.
47+
* @param {Type} postType The post type definition.
48+
* @return {boolean} Whether to sync this meta property to sync.
49+
*/
50+
const shouldSync = Boolean(
51+
applyFilters(
52+
'sync.shouldSyncMeta',
53+
! metaKey.startsWith( '_' ),
54+
metaKey,
55+
postType.slug,
56+
postType
57+
)
58+
);
59+
60+
decisionMap.set( metaKey, shouldSync );
61+
62+
return shouldSync;
63+
}

packages/core-data/src/utils/crdt.ts

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { type Post } from '../entity-types/post';
2121
import { type Type } from '../entity-types';
2222
import { CRDT_RECORD_MAP_KEY } from '../sync';
2323
import type { WPBlockSelection, WPSelection } from '../types';
24+
import { shouldSyncMetaForPostType } from './crdt-meta';
2425

2526
export type PostChanges = Partial< Post > & {
2627
blocks?: Block[];
@@ -42,6 +43,7 @@ const allowedPostProperties = new Set< string >( [
4243
'featured_media',
4344
'format',
4445
'ping_status',
46+
'meta',
4547
'slug',
4648
'status',
4749
'sticky',
@@ -98,7 +100,7 @@ export function defaultApplyChangesToCRDTDoc(
98100
export function applyPostChangesToCRDTDoc(
99101
ydoc: CRDTDoc,
100102
changes: PostChanges,
101-
postType: Type // eslint-disable-line @typescript-eslint/no-unused-vars
103+
postType: Type
102104
): void {
103105
const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY );
104106

@@ -146,6 +148,38 @@ export function applyPostChangesToCRDTDoc(
146148
break;
147149
}
148150

151+
// "Meta" is overloaded term; here, it refers to post meta.
152+
case 'meta': {
153+
let metaMap = ymap.get( 'meta' ) as Y.Map< unknown >;
154+
155+
// Initialize.
156+
if ( ! ( metaMap instanceof Y.Map ) ) {
157+
metaMap = new Y.Map();
158+
setValue( metaMap );
159+
}
160+
161+
// Iterate over each meta property in the new value and merge it if it
162+
// should be synced.
163+
Object.entries( newValue ?? {} ).forEach(
164+
( [ metaKey, metaValue ] ) => {
165+
if (
166+
! shouldSyncMetaForPostType( metaKey, postType )
167+
) {
168+
return;
169+
}
170+
171+
mergeValue(
172+
metaMap.get( metaKey ), // current value in CRDT
173+
metaValue, // new value from changes
174+
( updatedMetaValue: unknown ): void => {
175+
metaMap.set( metaKey, updatedMetaValue );
176+
}
177+
);
178+
}
179+
);
180+
break;
181+
}
182+
149183
case 'slug': {
150184
// Do not sync an empty slug. This indicates that the post is using
151185
// the default auto-generated slug.
@@ -204,11 +238,13 @@ export function defaultGetChangesFromCRDTDoc( crdtDoc: CRDTDoc ): ObjectData {
204238
export function getPostChangesFromCRDTDoc(
205239
ydoc: CRDTDoc,
206240
editedRecord: Post,
207-
postType: Type // eslint-disable-line @typescript-eslint/no-unused-vars
241+
postType: Type
208242
): PostChanges {
209243
const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY );
210244

211-
return Object.fromEntries(
245+
let allowedMetaChanges: Post[ 'meta' ] = {};
246+
247+
const changes = Object.fromEntries(
212248
Object.entries( ymap.toJSON() ).filter( ( [ key, newValue ] ) => {
213249
if ( ! allowedPostProperties.has( key ) ) {
214250
return false;
@@ -240,6 +276,24 @@ export function getPostChangesFromCRDTDoc(
240276
return haveValuesChanged( currentValue, newValue );
241277
}
242278

279+
case 'meta': {
280+
allowedMetaChanges = Object.fromEntries(
281+
Object.entries( newValue ?? {} ).filter(
282+
( [ metaKey ] ) =>
283+
shouldSyncMetaForPostType( metaKey, postType )
284+
)
285+
);
286+
287+
// Merge the allowed meta changes with the current meta values since
288+
// not all meta properties are synced.
289+
const mergedValue = {
290+
...( currentValue as PostChanges[ 'meta' ] ),
291+
...allowedMetaChanges,
292+
};
293+
294+
return haveValuesChanged( currentValue, mergedValue );
295+
}
296+
243297
case 'status': {
244298
// Do not sync an invalid status.
245299
if ( 'auto-draft' === newValue ) {
@@ -265,6 +319,17 @@ export function getPostChangesFromCRDTDoc(
265319
}
266320
} )
267321
);
322+
323+
// Meta changes must be merged with the edited record since not all meta
324+
// properties are synced.
325+
if ( 'object' === typeof changes.meta ) {
326+
changes.meta = {
327+
...editedRecord.meta,
328+
...allowedMetaChanges,
329+
};
330+
}
331+
332+
return changes;
268333
}
269334

270335
/**

packages/core-data/src/utils/test/crdt.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,54 @@ describe( 'crdt', () => {
152152
const blocks = map.get( 'blocks' );
153153
expect( blocks ).toBeInstanceOf( Y.Array );
154154
} );
155+
156+
it( 'syncs non-hidden meta fields', () => {
157+
const changes = {
158+
meta: {
159+
some_meta: 'new value',
160+
},
161+
};
162+
163+
const metaMap = new Y.Map< unknown >();
164+
metaMap.set( 'some_meta', 'old value' );
165+
map.set( 'meta', metaMap );
166+
167+
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
168+
169+
expect( metaMap.get( 'some_meta' ) ).toBe( 'new value' );
170+
} );
171+
172+
it( 'does not sync hidden meta fields', () => {
173+
const changes = {
174+
meta: {
175+
_private_meta: 'unsynced value',
176+
some_meta: 'new value',
177+
},
178+
};
179+
180+
const metaMap = new Y.Map< unknown >();
181+
metaMap.set( 'some_meta', 'old value' );
182+
map.set( 'meta', metaMap );
183+
184+
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
185+
186+
expect( metaMap.has( '_private_meta' ) ).toBe( false );
187+
expect( metaMap.get( 'some_meta' ) ).toBe( 'new value' );
188+
} );
189+
190+
it( 'initializes meta as Y.Map when not present', () => {
191+
const changes = {
192+
meta: {
193+
custom_field: 'value',
194+
},
195+
};
196+
197+
applyPostChangesToCRDTDoc( doc, changes, mockPostType );
198+
199+
const metaMap = map.get( 'meta' ) as Y.Map< unknown >;
200+
expect( metaMap ).toBeInstanceOf( Y.Map );
201+
expect( metaMap.get( 'custom_field' ) ).toBe( 'value' );
202+
} );
155203
} );
156204

157205
describe( 'getPostChangesFromCRDTDoc', () => {
@@ -250,5 +298,30 @@ describe( 'crdt', () => {
250298

251299
expect( changes ).toHaveProperty( 'blocks' );
252300
} );
301+
302+
it( 'includes meta in changes, preserving any unsynced fields', () => {
303+
map.set( 'meta', {
304+
_private_meta: 'ignored value',
305+
public_meta: 'new value',
306+
} );
307+
308+
const editedRecord = {
309+
meta: {
310+
_private_meta: 'preserved value',
311+
public_meta: 'old value',
312+
},
313+
} as unknown as Post;
314+
315+
const changes = getPostChangesFromCRDTDoc(
316+
doc,
317+
editedRecord,
318+
mockPostType
319+
);
320+
321+
expect( changes.meta ).toEqual( {
322+
_private_meta: 'preserved value', // preserved from edited record
323+
public_meta: 'new value', // from CRDT
324+
} );
325+
} );
253326
} );
254327
} );

0 commit comments

Comments
 (0)