PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / trunk
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin vtrunk
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 0.8.6 All 33 releases
desktop-mode / includes / notes / rest.php

rest.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin trunk, at includes/notes/rest.php

706 lines 23.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Pinned notes REST routes.
4 *
5 * Routes under `/desktop-mode/v1/notes`:
6 *
7 * GET /notes List the viewer's own notes
8 * (private + publish) plus every
9 * other user's public notes.
10 * POST /notes Create a note (author = viewer).
11 * PATCH /notes/(?P<id>\d+) Partial update — owner only.
12 * DELETE /notes/(?P<id>\d+) Soft-trash — owner only.
13 * POST /notes/(?P<id>\d+)/restore Untrash (Undo toast) — owner only.
14 * POST /notes/(?P<id>\d+)/convert Spawn a draft post from the note,
15 * then trash the note — owner only,
16 * requires the `edit_posts` cap.
17 *
18 * Ownership model: a note belongs to its `post_author`, and ONLY the
19 * owner may mutate it — deliberately including administrators. Public
20 * notes are a read-only broadcast surface; "manage other users'
21 * notes" is not a supported operation through this controller.
22 *
23 * Optimistic concurrency: PATCH accepts an `updatedAtMs` field
24 * carrying the client's last-seen modified timestamp; a mismatch
25 * returns 409 `openstation_notes_conflict` with the server copy in
26 * `data.current` so the client can re-render instead of clobbering.
27 *
28 * @package OpenStation
29 */
30
31 defined( 'ABSPATH' ) || exit;
32
33 /**
34 * Base permission: logged-in + OpenStation enabled.
35 *
36 * @return true|WP_Error
37 */
38 function openstation_notes_rest_permission() {
39 if ( ! is_user_logged_in() ) {
40 return new WP_Error( 'openstation_notes_unauthenticated', __( 'You must be logged in.', 'desktop-mode' ), array( 'status' => 401 ) );
41 }
42 if ( function_exists( 'openstation_is_enabled' ) && ! openstation_is_enabled( get_current_user_id() ) ) {
43 return new WP_Error( 'openstation_notes_disabled', __( 'OpenStation is not enabled for this user.', 'desktop-mode' ), array( 'status' => 403 ) );
44 }
45 return true;
46 }
47
48 /**
49 * Register the routes.
50 */
51 function openstation_notes_register_rest_routes() {
52 $ns = 'desktop-mode/v1';
53
54 register_rest_route(
55 $ns,
56 '/notes',
57 array(
58 array(
59 'methods' => WP_REST_Server::READABLE,
60 'permission_callback' => 'openstation_notes_rest_permission',
61 'callback' => 'openstation_notes_rest_list',
62 ),
63 array(
64 'methods' => WP_REST_Server::CREATABLE,
65 'permission_callback' => 'openstation_notes_rest_permission',
66 'callback' => 'openstation_notes_rest_create',
67 'args' => array(
68 'text' => array(
69 'type' => 'string',
70 'default' => '',
71 'sanitize_callback' => 'sanitize_textarea_field',
72 ),
73 'color' => array(
74 'type' => 'string',
75 'default' => 'butter',
76 'sanitize_callback' => 'openstation_notes_sanitize_color',
77 ),
78 'x' => array(
79 'type' => 'number',
80 'default' => 0.1,
81 ),
82 'y' => array(
83 'type' => 'number',
84 'default' => 0.1,
85 ),
86 'public' => array(
87 'type' => 'boolean',
88 'default' => false,
89 ),
90 'seed' => array(
91 'type' => 'integer',
92 'default' => 0,
93 'sanitize_callback' => 'absint',
94 ),
95 ),
96 ),
97 )
98 );
99
100 register_rest_route(
101 $ns,
102 '/notes/(?P<id>\d+)',
103 array(
104 array(
105 'methods' => WP_REST_Server::EDITABLE,
106 'permission_callback' => 'openstation_notes_rest_permission',
107 'callback' => 'openstation_notes_rest_update',
108 ),
109 array(
110 'methods' => WP_REST_Server::DELETABLE,
111 'permission_callback' => 'openstation_notes_rest_permission',
112 'callback' => 'openstation_notes_rest_delete',
113 ),
114 )
115 );
116
117 register_rest_route(
118 $ns,
119 '/notes/(?P<id>\d+)/restore',
120 array(
121 'methods' => WP_REST_Server::CREATABLE,
122 'permission_callback' => 'openstation_notes_rest_permission',
123 'callback' => 'openstation_notes_rest_restore',
124 )
125 );
126
127 register_rest_route(
128 $ns,
129 '/notes/(?P<id>\d+)/convert',
130 array(
131 'methods' => WP_REST_Server::CREATABLE,
132 'permission_callback' => 'openstation_notes_rest_permission',
133 'callback' => 'openstation_notes_rest_convert',
134 )
135 );
136 }
137 add_action( 'rest_api_init', 'openstation_notes_register_rest_routes' );
138
139 /**
140 * Fetch a note post, or a WP_Error when it doesn't exist / isn't a note.
141 *
142 * @param int $id Post ID.
143 * @param bool $allow_trash Whether a trashed note is acceptable (restore path).
144 * @return WP_Post|WP_Error
145 */
146 function openstation_notes_get_note( $id, $allow_trash = false ) {
147 $post = get_post( (int) $id );
148 if ( ! $post instanceof WP_Post || OPENSTATION_NOTES_POST_TYPE !== $post->post_type ) {
149 return new WP_Error( 'openstation_notes_not_found', __( 'Note not found.', 'desktop-mode' ), array( 'status' => 404 ) );
150 }
151 $allowed = $allow_trash ? array( 'private', 'publish', 'trash' ) : array( 'private', 'publish' );
152 if ( ! in_array( $post->post_status, $allowed, true ) ) {
153 return new WP_Error( 'openstation_notes_not_found', __( 'Note not found.', 'desktop-mode' ), array( 'status' => 404 ) );
154 }
155 return $post;
156 }
157
158 /**
159 * Owner gate. Only the note's author may mutate it — including admins.
160 *
161 * Returning 404 (not 403) for other users' PRIVATE notes would leak
162 * less, but the id namespace is shared with public notes anyway and
163 * a mutation attempt on a visible public note deserves an honest 403.
164 *
165 * @param WP_Post $post Note post.
166 * @return true|WP_Error
167 */
168 function openstation_notes_require_owner( $post ) {
169 if ( get_current_user_id() !== (int) $post->post_author ) {
170 return new WP_Error( 'openstation_notes_forbidden', __( 'Only the note owner can change it.', 'desktop-mode' ), array( 'status' => 403 ) );
171 }
172 return true;
173 }
174
175 /**
176 * Modified timestamp in milliseconds (GMT).
177 *
178 * Second precision (WordPress stores no sub-second post dates) — the
179 * client treats the value as an opaque token and echoes it back.
180 *
181 * @param WP_Post $post Post.
182 * @return int
183 */
184 function openstation_notes_modified_ms( $post ) {
185 return (int) get_post_modified_time( 'U', true, $post ) * 1000;
186 }
187
188 /**
189 * Serialize a note for the wire.
190 *
191 * @param WP_Post $post Note post.
192 * @return array
193 */
194 function openstation_notes_prepare( $post ) {
195 $owner_id = (int) $post->post_author;
196 $owner = get_userdata( $owner_id );
197
198 return array(
199 'id' => (int) $post->ID,
200 'text' => (string) get_post_field( 'post_content', $post, 'raw' ),
201 'color' => openstation_notes_sanitize_color( get_post_meta( $post->ID, '_wpd_note_color', true ) ),
202 'x' => openstation_notes_sanitize_fraction( get_post_meta( $post->ID, '_wpd_note_x', true ) ),
203 'y' => openstation_notes_sanitize_fraction( get_post_meta( $post->ID, '_wpd_note_y', true ) ),
204 'z' => (int) get_post_meta( $post->ID, '_wpd_note_z', true ),
205 'public' => 'publish' === $post->post_status,
206 'seed' => (int) get_post_meta( $post->ID, '_wpd_note_seed', true ),
207 'ownerId' => $owner_id,
208 'ownerName' => $owner instanceof WP_User ? (string) $owner->display_name : '',
209 'ownerAvatar' => (string) get_avatar_url( $owner_id, array( 'size' => 48 ) ),
210 'canEdit' => get_current_user_id() === $owner_id,
211 'updatedAtMs' => openstation_notes_modified_ms( $post ),
212 );
213 }
214
215 /**
216 * Derive the post title from the note text (first non-empty line).
217 *
218 * Only used for admin-side lists / exports — the shell never shows it.
219 *
220 * @param string $text Note text.
221 * @return string
222 */
223 function openstation_notes_derive_title( $text ) {
224 foreach ( preg_split( '/\r\n|\r|\n/', (string) $text ) as $line ) {
225 $line = trim( $line );
226 if ( '' !== $line ) {
227 return mb_substr( sanitize_text_field( $line ), 0, 80 );
228 }
229 }
230 return __( 'Note', 'desktop-mode' );
231 }
232
233 /**
234 * GET /notes — own notes (private + publish) ∪ others' publish.
235 *
236 * @return WP_REST_Response
237 */
238 function openstation_notes_rest_list() {
239 $user_id = get_current_user_id();
240
241 // Newest first: the per-half cap exists as a runaway guard, and
242 // when it ever bites it must drop the OLDEST notes — capping an
243 // ascending list would silently hide every recently pinned note
244 // (and the boot high-water would stop the Heartbeat delta from
245 // ever backfilling them).
246 $own = new WP_Query(
247 array(
248 'post_type' => OPENSTATION_NOTES_POST_TYPE,
249 'post_status' => array( 'private', 'publish' ),
250 'author' => $user_id,
251 'posts_per_page' => 200,
252 'orderby' => 'date',
253 'order' => 'DESC',
254 'no_found_rows' => true,
255 )
256 );
257
258 $public = new WP_Query(
259 array(
260 'post_type' => OPENSTATION_NOTES_POST_TYPE,
261 'post_status' => 'publish',
262 'author__not_in' => array( $user_id ),
263 'posts_per_page' => 200,
264 'orderby' => 'date',
265 'order' => 'DESC',
266 'no_found_rows' => true,
267 )
268 );
269
270 $notes = array();
271 foreach ( array_merge( (array) $own->posts, (array) $public->posts ) as $post ) {
272 $notes[] = openstation_notes_prepare( $post );
273 }
274 wp_reset_postdata();
275
276 return rest_ensure_response( array( 'notes' => $notes ) );
277 }
278
279 /**
280 * POST /notes.
281 *
282 * @param WP_REST_Request $request Request.
283 * @return WP_REST_Response|WP_Error
284 */
285 function openstation_notes_rest_create( $request ) {
286 /**
287 * Filters whether the current user may create a note.
288 *
289 * Notes default to any logged-in openstation user — including
290 * publishing PUBLIC notes onto every other user's wallpaper.
291 * Sites that want to restrict that (by role, capability, or the
292 * request's `public` flag) hook here.
293 *
294 * @param bool $can_create Whether creation is allowed. Default true.
295 * @param int $user_id Current user id.
296 * @param WP_REST_Request $request The create request (inspect `public`, `text`, ...).
297 */
298 $can_create = apply_filters( 'openstation_notes_user_can_create', true, get_current_user_id(), $request );
299 if ( ! $can_create ) {
300 return new WP_Error( 'openstation_notes_forbidden', __( 'You are not allowed to create notes.', 'desktop-mode' ), array( 'status' => 403 ) );
301 }
302
303 $text = sanitize_textarea_field( (string) $request['text'] );
304
305 $post_id = wp_insert_post(
306 array(
307 'post_type' => OPENSTATION_NOTES_POST_TYPE,
308 'post_status' => $request['public'] ? 'publish' : 'private',
309 'post_author' => get_current_user_id(),
310 'post_title' => openstation_notes_derive_title( $text ),
311 'post_content' => $text,
312 ),
313 true
314 );
315 if ( is_wp_error( $post_id ) ) {
316 $post_id->add_data( array( 'status' => 500 ) );
317 return $post_id;
318 }
319
320 update_post_meta( $post_id, '_wpd_note_color', openstation_notes_sanitize_color( $request['color'] ) );
321 update_post_meta( $post_id, '_wpd_note_x', openstation_notes_sanitize_fraction( $request['x'] ) );
322 update_post_meta( $post_id, '_wpd_note_y', openstation_notes_sanitize_fraction( $request['y'] ) );
323 update_post_meta( $post_id, '_wpd_note_z', openstation_notes_next_z() );
324 // The jitter seed is written ONCE, here — PATCH never touches it,
325 // so editing a note's text never re-tilts its paper. The client
326 // sends its own text hash (keeps the optimistic render identical);
327 // fall back to a server-side hash when absent.
328 $seed = absint( $request['seed'] );
329 if ( 0 === $seed ) {
330 $seed = absint( crc32( $text ) ) % 2147483647;
331 $seed = $seed > 0 ? $seed : 1;
332 }
333 update_post_meta( $post_id, '_wpd_note_seed', $seed );
334
335 return rest_ensure_response( openstation_notes_prepare( get_post( $post_id ) ) );
336 }
337
338 /**
339 * Next z-order value across all live (non-trashed) notes.
340 *
341 * Deliberately site-wide, not per-owner: public notes from different
342 * owners stack on the same wall, so a fresh note must land above
343 * everyone's papers. Cheap max-of-meta walk — note counts are tiny
344 * (a wall of paper, not a database of record).
345 *
346 * @return int
347 */
348 function openstation_notes_next_z() {
349 global $wpdb;
350 $max = $wpdb->get_var(
351 $wpdb->prepare(
352 "SELECT MAX( CAST( pm.meta_value AS UNSIGNED ) )
353 FROM {$wpdb->postmeta} pm
354 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
355 WHERE pm.meta_key = %s AND p.post_type = %s AND p.post_status IN ( 'private', 'publish' )",
356 '_wpd_note_z',
357 OPENSTATION_NOTES_POST_TYPE
358 )
359 );
360 return (int) $max + 1;
361 }
362
363 /**
364 * PATCH /notes/:id — partial update, owner only.
365 *
366 * @param WP_REST_Request $request Request.
367 * @return WP_REST_Response|WP_Error
368 */
369 function openstation_notes_rest_update( $request ) {
370 $post = openstation_notes_get_note( $request['id'] );
371 if ( is_wp_error( $post ) ) {
372 return $post;
373 }
374 $owner = openstation_notes_require_owner( $post );
375 if ( is_wp_error( $owner ) ) {
376 return $owner;
377 }
378
379 // Optimistic concurrency — a stale token means another session
380 // (or device) changed the note since this client last saw it.
381 $client_ms = $request['updatedAtMs'];
382 if ( null !== $client_ms && openstation_notes_modified_ms( $post ) !== (int) $client_ms ) {
383 return new WP_Error(
384 'openstation_notes_conflict',
385 __( 'The note was changed by another session.', 'desktop-mode' ),
386 array(
387 'status' => 409,
388 'current' => openstation_notes_prepare( $post ),
389 )
390 );
391 }
392
393 $update = array( 'ID' => $post->ID );
394
395 if ( null !== $request['text'] ) {
396 $text = sanitize_textarea_field( (string) $request['text'] );
397 $update['post_content'] = $text;
398 $update['post_title'] = openstation_notes_derive_title( $text );
399 }
400 if ( null !== $request['public'] ) {
401 $update['post_status'] = rest_sanitize_boolean( $request['public'] ) ? 'publish' : 'private';
402 }
403
404 if ( null !== $request['color'] ) {
405 update_post_meta( $post->ID, '_wpd_note_color', openstation_notes_sanitize_color( $request['color'] ) );
406 }
407 if ( null !== $request['x'] ) {
408 update_post_meta( $post->ID, '_wpd_note_x', openstation_notes_sanitize_fraction( $request['x'] ) );
409 }
410 if ( null !== $request['y'] ) {
411 update_post_meta( $post->ID, '_wpd_note_y', openstation_notes_sanitize_fraction( $request['y'] ) );
412 }
413 if ( null !== $request['z'] ) {
414 update_post_meta( $post->ID, '_wpd_note_z', absint( $request['z'] ) );
415 }
416
417 // Always run the post update — even a meta-only PATCH must bump
418 // `post_modified` so the concurrency token advances and the
419 // Heartbeat delta query sees the move.
420 $result = wp_update_post( $update, true );
421 if ( is_wp_error( $result ) ) {
422 $result->add_data( array( 'status' => 500 ) );
423 return $result;
424 }
425
426 return rest_ensure_response( openstation_notes_prepare( get_post( $post->ID ) ) );
427 }
428
429 /**
430 * DELETE /notes/:id — soft-trash, owner only.
431 *
432 * @param WP_REST_Request $request Request.
433 * @return WP_REST_Response|WP_Error
434 */
435 function openstation_notes_rest_delete( $request ) {
436 $post = openstation_notes_get_note( $request['id'] );
437 if ( is_wp_error( $post ) ) {
438 return $post;
439 }
440 $owner = openstation_notes_require_owner( $post );
441 if ( is_wp_error( $owner ) ) {
442 return $owner;
443 }
444
445 if ( ! wp_trash_post( $post->ID ) ) {
446 return new WP_Error( 'openstation_notes_trash_failed', __( 'Could not move the note to the trash.', 'desktop-mode' ), array( 'status' => 500 ) );
447 }
448
449 return rest_ensure_response(
450 array(
451 'trashed' => true,
452 'id' => (int) $post->ID,
453 )
454 );
455 }
456
457 /**
458 * POST /notes/:id/restore — untrash (Undo), owner only.
459 *
460 * @param WP_REST_Request $request Request.
461 * @return WP_REST_Response|WP_Error
462 */
463 function openstation_notes_rest_restore( $request ) {
464 $post = openstation_notes_get_note( $request['id'], true );
465 if ( is_wp_error( $post ) ) {
466 return $post;
467 }
468 $owner = openstation_notes_require_owner( $post );
469 if ( is_wp_error( $owner ) ) {
470 return $owner;
471 }
472 if ( 'trash' !== $post->post_status ) {
473 return rest_ensure_response( openstation_notes_prepare( $post ) );
474 }
475
476 if ( ! wp_untrash_post( $post->ID ) ) {
477 return new WP_Error( 'openstation_notes_restore_failed', __( 'Could not restore the note.', 'desktop-mode' ), array( 'status' => 500 ) );
478 }
479
480 // If this note was trashed by a "convert to post" action, undoing
481 // the conversion must also discard the draft it spawned — otherwise
482 // Undo would leave the note back on the wall AND a stray draft. The
483 // link is written by the convert route (`_wpd_note_converted_post`)
484 // and consumed once here. Only a still-present draft is trashed; a
485 // draft the user already published or trashed themselves is left be.
486 $converted_post_id = (int) get_post_meta( $post->ID, '_wpd_note_converted_post', true );
487 if ( $converted_post_id > 0 ) {
488 delete_post_meta( $post->ID, '_wpd_note_converted_post' );
489 $draft = get_post( $converted_post_id );
490 if ( $draft instanceof WP_Post && 'draft' === $draft->post_status ) {
491 wp_trash_post( $converted_post_id );
492 }
493 }
494
495 return rest_ensure_response( openstation_notes_prepare( get_post( $post->ID ) ) );
496 }
497
498 /**
499 * Convert a note's plain text into Gutenberg paragraph-block markup.
500 *
501 * Blank lines split paragraphs; single newlines within a paragraph
502 * become `<br>`. The result lands clean in the block editor rather
503 * than as one classic-HTML blob.
504 *
505 * @param string $text Note text.
506 * @return string Serialized block markup (empty string for empty text).
507 */
508 function openstation_notes_text_to_blocks( $text ) {
509 $text = str_replace( array( "\r\n", "\r" ), "\n", (string) $text );
510 $paragraphs = preg_split( '/\n{2,}/', trim( $text ) );
511 $blocks = array();
512 foreach ( $paragraphs as $paragraph ) {
513 $paragraph = trim( $paragraph, "\n" );
514 if ( '' === $paragraph ) {
515 continue;
516 }
517 $html = nl2br( esc_html( $paragraph ), false );
518 $blocks[] = "<!-- wp:paragraph -->\n<p>{$html}</p>\n<!-- /wp:paragraph -->";
519 }
520 return implode( "\n\n", $blocks );
521 }
522
523 /**
524 * POST /notes/:id/convert — spawn a draft post from a note, then trash
525 * the note. Owner only, and the owner must be able to author posts.
526 *
527 * The note is trashed (not hard-deleted) and linked to its new draft
528 * via `_wpd_note_converted_post` so the standard restore route can undo
529 * both sides of the conversion (see `openstation_notes_rest_restore`).
530 *
531 * @param WP_REST_Request $request Request.
532 * @return WP_REST_Response|WP_Error
533 */
534 function openstation_notes_rest_convert( $request ) {
535 $post = openstation_notes_get_note( $request['id'] );
536 if ( is_wp_error( $post ) ) {
537 return $post;
538 }
539 $owner = openstation_notes_require_owner( $post );
540 if ( is_wp_error( $owner ) ) {
541 return $owner;
542 }
543 if ( ! current_user_can( 'edit_posts' ) ) {
544 return new WP_Error( 'openstation_notes_cannot_create_posts', __( 'You are not allowed to create posts.', 'desktop-mode' ), array( 'status' => 403 ) );
545 }
546
547 $text = (string) get_post_field( 'post_content', $post, 'raw' );
548 $title = openstation_notes_derive_title( $text );
549
550 /**
551 * Filters the arguments used to create the draft post from a note.
552 *
553 * Hook here to change the post type/status, assign a category or
554 * author, or wrap the body in different block markup.
555 *
556 * @param array $post_args Args passed to `wp_insert_post()`.
557 * @param WP_Post $post The source note.
558 * @param WP_REST_Request $request The convert request.
559 */
560 $post_args = apply_filters(
561 'openstation_notes_convert_post_args',
562 array(
563 'post_type' => 'post',
564 'post_status' => 'draft',
565 'post_author' => (int) $post->post_author,
566 'post_title' => $title,
567 'post_content' => openstation_notes_text_to_blocks( $text ),
568 ),
569 $post,
570 $request
571 );
572
573 $new_post_id = wp_insert_post( $post_args, true );
574 if ( is_wp_error( $new_post_id ) ) {
575 $new_post_id->add_data( array( 'status' => 500 ) );
576 return $new_post_id;
577 }
578
579 // Link the note to its draft BEFORE trashing so restore can reverse
580 // both sides. If the trash fails, roll the draft back so a failed
581 // conversion never leaves an orphan draft behind.
582 update_post_meta( $post->ID, '_wpd_note_converted_post', (int) $new_post_id );
583 if ( ! wp_trash_post( $post->ID ) ) {
584 wp_delete_post( $new_post_id, true );
585 delete_post_meta( $post->ID, '_wpd_note_converted_post' );
586 return new WP_Error( 'openstation_notes_convert_failed', __( 'Could not convert the note to a post.', 'desktop-mode' ), array( 'status' => 500 ) );
587 }
588
589 /**
590 * Fires after a note has been converted to a draft post.
591 *
592 * @param int $new_post_id The draft post id.
593 * @param WP_Post $post The source note (now trashed).
594 * @param WP_REST_Request $request The convert request.
595 */
596 do_action( 'openstation_notes_converted', (int) $new_post_id, $post, $request );
597
598 return rest_ensure_response(
599 array(
600 'noteId' => (int) $post->ID,
601 'postId' => (int) $new_post_id,
602 'editUrl' => (string) get_edit_post_link( $new_post_id, 'raw' ),
603 )
604 );
605 }
606
607 /**
608 * Whether the current user's desktop would show any pinned notes.
609 *
610 * The presence hint the boot config ships as `hasNotes`: the notes
611 * bundle is presence-gated client-side (`src/notes/sentinel.ts`), so
612 * a user with no notes never downloads it — and never fires the
613 * boot-time list request the layer used to make unconditionally.
614 *
615 * **Steady-state cost: zero queries.** The computed answer is cached
616 * in user meta, stamped with a revision the site bumps whenever any
617 * note changes (`openstation_notes_bump_rev()` below). The revision
618 * lives in an autoloaded option and the user's meta cache is already
619 * primed on every admin request, so a boot between note changes
620 * reads two warm caches and touches the database not at all. Only
621 * the first boot after a note is created / deleted / re-scoped runs
622 * the probes again — two `fields => ids`, one-row queries at most:
623 * anyone's public note first, the user's own private ones second,
624 * mirroring the visibility rule the list route enforces.
625 *
626 * @return bool
627 */
628 function openstation_notes_user_has_any() {
629 $rev = (string) get_option( 'desktop_mode_notes_rev', '0' );
630 $user_id = get_current_user_id();
631 $cached = (string) get_user_meta( $user_id, '_desktop_mode_has_notes', true );
632 if ( '' !== $cached ) {
633 list( $cached_rev, $cached_value ) = array_pad( explode( ':', $cached, 2 ), 2, '' );
634 if ( $cached_rev === $rev ) {
635 return '1' === $cached_value;
636 }
637 }
638
639 $public = new WP_Query(
640 array(
641 'post_type' => OPENSTATION_NOTES_POST_TYPE,
642 'post_status' => 'publish',
643 'posts_per_page' => 1,
644 'fields' => 'ids',
645 'no_found_rows' => true,
646 )
647 );
648 $has = (bool) $public->posts;
649 if ( ! $has ) {
650 $own = new WP_Query(
651 array(
652 'post_type' => OPENSTATION_NOTES_POST_TYPE,
653 'post_status' => 'private',
654 'author' => $user_id,
655 'posts_per_page' => 1,
656 'fields' => 'ids',
657 'no_found_rows' => true,
658 )
659 );
660 $has = (bool) $own->posts;
661 }
662
663 update_user_meta( $user_id, '_desktop_mode_has_notes', $rev . ':' . ( $has ? '1' : '0' ) );
664 return $has;
665 }
666
667 /**
668 * Invalidate every user's cached `hasNotes` answer.
669 *
670 * One autoloaded revision counter instead of per-user cache deletes:
671 * a public note's existence changes the answer for EVERY user, and
672 * enumerating users to clear meta would be the expensive thing this
673 * cache exists to avoid. Bumping the rev makes all stamped answers
674 * stale at the cost of one option write per note change — and note
675 * changes are rare next to boots.
676 *
677 * @param int|WP_Post $post Post id or object being changed.
678 * @return void
679 */
680 function openstation_notes_bump_rev( $post ) {
681 $post = get_post( $post );
682 if ( ! $post instanceof WP_Post || OPENSTATION_NOTES_POST_TYPE !== $post->post_type ) {
683 return;
684 }
685 update_option( 'desktop_mode_notes_rev', (string) time() . '.' . wp_rand( 0, 999 ), true );
686 }
687
688 /**
689 * Every path a note can change through: status moves (create,
690 * publish/private flips, trash, restore) fire
691 * `transition_post_status`; hard deletes fire `deleted_post`.
692 *
693 * @param string $new_status New status.
694 * @param string $old_status Old status.
695 * @param WP_Post $post The post.
696 * @return void
697 */
698 function openstation_notes_bump_rev_on_transition( $new_status, $old_status, $post ) {
699 if ( $new_status === $old_status ) {
700 return;
701 }
702 openstation_notes_bump_rev( $post );
703 }
704 add_action( 'transition_post_status', 'openstation_notes_bump_rev_on_transition', 10, 3 );
705 add_action( 'deleted_post', 'openstation_notes_bump_rev' );
706