\d+) Partial update — owner only. * DELETE /notes/(?P\d+) Soft-trash — owner only. * POST /notes/(?P\d+)/restore Untrash (Undo toast) — owner only. * POST /notes/(?P\d+)/convert Spawn a draft post from the note, * then trash the note — owner only, * requires the `edit_posts` cap. * * Ownership model: a note belongs to its `post_author`, and ONLY the * owner may mutate it — deliberately including administrators. Public * notes are a read-only broadcast surface; "manage other users' * notes" is not a supported operation through this controller. * * Optimistic concurrency: PATCH accepts an `updatedAtMs` field * carrying the client's last-seen modified timestamp; a mismatch * returns 409 `openstation_notes_conflict` with the server copy in * `data.current` so the client can re-render instead of clobbering. * * @package OpenStation */ defined( 'ABSPATH' ) || exit; /** * Base permission: logged-in + OpenStation enabled. * * @return true|WP_Error */ function openstation_notes_rest_permission() { if ( ! is_user_logged_in() ) { return new WP_Error( 'openstation_notes_unauthenticated', __( 'You must be logged in.', 'desktop-mode' ), array( 'status' => 401 ) ); } if ( function_exists( 'openstation_is_enabled' ) && ! openstation_is_enabled( get_current_user_id() ) ) { return new WP_Error( 'openstation_notes_disabled', __( 'OpenStation is not enabled for this user.', 'desktop-mode' ), array( 'status' => 403 ) ); } return true; } /** * Register the routes. */ function openstation_notes_register_rest_routes() { $ns = 'desktop-mode/v1'; register_rest_route( $ns, '/notes', array( array( 'methods' => WP_REST_Server::READABLE, 'permission_callback' => 'openstation_notes_rest_permission', 'callback' => 'openstation_notes_rest_list', ), array( 'methods' => WP_REST_Server::CREATABLE, 'permission_callback' => 'openstation_notes_rest_permission', 'callback' => 'openstation_notes_rest_create', 'args' => array( 'text' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_textarea_field', ), 'color' => array( 'type' => 'string', 'default' => 'butter', 'sanitize_callback' => 'openstation_notes_sanitize_color', ), 'x' => array( 'type' => 'number', 'default' => 0.1, ), 'y' => array( 'type' => 'number', 'default' => 0.1, ), 'public' => array( 'type' => 'boolean', 'default' => false, ), 'seed' => array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ), ), ), ) ); register_rest_route( $ns, '/notes/(?P\d+)', array( array( 'methods' => WP_REST_Server::EDITABLE, 'permission_callback' => 'openstation_notes_rest_permission', 'callback' => 'openstation_notes_rest_update', ), array( 'methods' => WP_REST_Server::DELETABLE, 'permission_callback' => 'openstation_notes_rest_permission', 'callback' => 'openstation_notes_rest_delete', ), ) ); register_rest_route( $ns, '/notes/(?P\d+)/restore', array( 'methods' => WP_REST_Server::CREATABLE, 'permission_callback' => 'openstation_notes_rest_permission', 'callback' => 'openstation_notes_rest_restore', ) ); register_rest_route( $ns, '/notes/(?P\d+)/convert', array( 'methods' => WP_REST_Server::CREATABLE, 'permission_callback' => 'openstation_notes_rest_permission', 'callback' => 'openstation_notes_rest_convert', ) ); } add_action( 'rest_api_init', 'openstation_notes_register_rest_routes' ); /** * Fetch a note post, or a WP_Error when it doesn't exist / isn't a note. * * @param int $id Post ID. * @param bool $allow_trash Whether a trashed note is acceptable (restore path). * @return WP_Post|WP_Error */ function openstation_notes_get_note( $id, $allow_trash = false ) { $post = get_post( (int) $id ); if ( ! $post instanceof WP_Post || OPENSTATION_NOTES_POST_TYPE !== $post->post_type ) { return new WP_Error( 'openstation_notes_not_found', __( 'Note not found.', 'desktop-mode' ), array( 'status' => 404 ) ); } $allowed = $allow_trash ? array( 'private', 'publish', 'trash' ) : array( 'private', 'publish' ); if ( ! in_array( $post->post_status, $allowed, true ) ) { return new WP_Error( 'openstation_notes_not_found', __( 'Note not found.', 'desktop-mode' ), array( 'status' => 404 ) ); } return $post; } /** * Owner gate. Only the note's author may mutate it — including admins. * * Returning 404 (not 403) for other users' PRIVATE notes would leak * less, but the id namespace is shared with public notes anyway and * a mutation attempt on a visible public note deserves an honest 403. * * @param WP_Post $post Note post. * @return true|WP_Error */ function openstation_notes_require_owner( $post ) { if ( get_current_user_id() !== (int) $post->post_author ) { return new WP_Error( 'openstation_notes_forbidden', __( 'Only the note owner can change it.', 'desktop-mode' ), array( 'status' => 403 ) ); } return true; } /** * Modified timestamp in milliseconds (GMT). * * Second precision (WordPress stores no sub-second post dates) — the * client treats the value as an opaque token and echoes it back. * * @param WP_Post $post Post. * @return int */ function openstation_notes_modified_ms( $post ) { return (int) get_post_modified_time( 'U', true, $post ) * 1000; } /** * Serialize a note for the wire. * * @param WP_Post $post Note post. * @return array */ function openstation_notes_prepare( $post ) { $owner_id = (int) $post->post_author; $owner = get_userdata( $owner_id ); return array( 'id' => (int) $post->ID, 'text' => (string) get_post_field( 'post_content', $post, 'raw' ), 'color' => openstation_notes_sanitize_color( get_post_meta( $post->ID, '_wpd_note_color', true ) ), 'x' => openstation_notes_sanitize_fraction( get_post_meta( $post->ID, '_wpd_note_x', true ) ), 'y' => openstation_notes_sanitize_fraction( get_post_meta( $post->ID, '_wpd_note_y', true ) ), 'z' => (int) get_post_meta( $post->ID, '_wpd_note_z', true ), 'public' => 'publish' === $post->post_status, 'seed' => (int) get_post_meta( $post->ID, '_wpd_note_seed', true ), 'ownerId' => $owner_id, 'ownerName' => $owner instanceof WP_User ? (string) $owner->display_name : '', 'ownerAvatar' => (string) get_avatar_url( $owner_id, array( 'size' => 48 ) ), 'canEdit' => get_current_user_id() === $owner_id, 'updatedAtMs' => openstation_notes_modified_ms( $post ), ); } /** * Derive the post title from the note text (first non-empty line). * * Only used for admin-side lists / exports — the shell never shows it. * * @param string $text Note text. * @return string */ function openstation_notes_derive_title( $text ) { foreach ( preg_split( '/\r\n|\r|\n/', (string) $text ) as $line ) { $line = trim( $line ); if ( '' !== $line ) { return mb_substr( sanitize_text_field( $line ), 0, 80 ); } } return __( 'Note', 'desktop-mode' ); } /** * GET /notes — own notes (private + publish) ∪ others' publish. * * @return WP_REST_Response */ function openstation_notes_rest_list() { $user_id = get_current_user_id(); // Newest first: the per-half cap exists as a runaway guard, and // when it ever bites it must drop the OLDEST notes — capping an // ascending list would silently hide every recently pinned note // (and the boot high-water would stop the Heartbeat delta from // ever backfilling them). $own = new WP_Query( array( 'post_type' => OPENSTATION_NOTES_POST_TYPE, 'post_status' => array( 'private', 'publish' ), 'author' => $user_id, 'posts_per_page' => 200, 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, ) ); $public = new WP_Query( array( 'post_type' => OPENSTATION_NOTES_POST_TYPE, 'post_status' => 'publish', 'author__not_in' => array( $user_id ), 'posts_per_page' => 200, 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, ) ); $notes = array(); foreach ( array_merge( (array) $own->posts, (array) $public->posts ) as $post ) { $notes[] = openstation_notes_prepare( $post ); } wp_reset_postdata(); return rest_ensure_response( array( 'notes' => $notes ) ); } /** * POST /notes. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function openstation_notes_rest_create( $request ) { /** * Filters whether the current user may create a note. * * Notes default to any logged-in openstation user — including * publishing PUBLIC notes onto every other user's wallpaper. * Sites that want to restrict that (by role, capability, or the * request's `public` flag) hook here. * * @param bool $can_create Whether creation is allowed. Default true. * @param int $user_id Current user id. * @param WP_REST_Request $request The create request (inspect `public`, `text`, ...). */ $can_create = apply_filters( 'openstation_notes_user_can_create', true, get_current_user_id(), $request ); if ( ! $can_create ) { return new WP_Error( 'openstation_notes_forbidden', __( 'You are not allowed to create notes.', 'desktop-mode' ), array( 'status' => 403 ) ); } $text = sanitize_textarea_field( (string) $request['text'] ); $post_id = wp_insert_post( array( 'post_type' => OPENSTATION_NOTES_POST_TYPE, 'post_status' => $request['public'] ? 'publish' : 'private', 'post_author' => get_current_user_id(), 'post_title' => openstation_notes_derive_title( $text ), 'post_content' => $text, ), true ); if ( is_wp_error( $post_id ) ) { $post_id->add_data( array( 'status' => 500 ) ); return $post_id; } update_post_meta( $post_id, '_wpd_note_color', openstation_notes_sanitize_color( $request['color'] ) ); update_post_meta( $post_id, '_wpd_note_x', openstation_notes_sanitize_fraction( $request['x'] ) ); update_post_meta( $post_id, '_wpd_note_y', openstation_notes_sanitize_fraction( $request['y'] ) ); update_post_meta( $post_id, '_wpd_note_z', openstation_notes_next_z() ); // The jitter seed is written ONCE, here — PATCH never touches it, // so editing a note's text never re-tilts its paper. The client // sends its own text hash (keeps the optimistic render identical); // fall back to a server-side hash when absent. $seed = absint( $request['seed'] ); if ( 0 === $seed ) { $seed = absint( crc32( $text ) ) % 2147483647; $seed = $seed > 0 ? $seed : 1; } update_post_meta( $post_id, '_wpd_note_seed', $seed ); return rest_ensure_response( openstation_notes_prepare( get_post( $post_id ) ) ); } /** * Next z-order value across all live (non-trashed) notes. * * Deliberately site-wide, not per-owner: public notes from different * owners stack on the same wall, so a fresh note must land above * everyone's papers. Cheap max-of-meta walk — note counts are tiny * (a wall of paper, not a database of record). * * @return int */ function openstation_notes_next_z() { global $wpdb; $max = $wpdb->get_var( $wpdb->prepare( "SELECT MAX( CAST( pm.meta_value AS UNSIGNED ) ) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND p.post_type = %s AND p.post_status IN ( 'private', 'publish' )", '_wpd_note_z', OPENSTATION_NOTES_POST_TYPE ) ); return (int) $max + 1; } /** * PATCH /notes/:id — partial update, owner only. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function openstation_notes_rest_update( $request ) { $post = openstation_notes_get_note( $request['id'] ); if ( is_wp_error( $post ) ) { return $post; } $owner = openstation_notes_require_owner( $post ); if ( is_wp_error( $owner ) ) { return $owner; } // Optimistic concurrency — a stale token means another session // (or device) changed the note since this client last saw it. $client_ms = $request['updatedAtMs']; if ( null !== $client_ms && openstation_notes_modified_ms( $post ) !== (int) $client_ms ) { return new WP_Error( 'openstation_notes_conflict', __( 'The note was changed by another session.', 'desktop-mode' ), array( 'status' => 409, 'current' => openstation_notes_prepare( $post ), ) ); } $update = array( 'ID' => $post->ID ); if ( null !== $request['text'] ) { $text = sanitize_textarea_field( (string) $request['text'] ); $update['post_content'] = $text; $update['post_title'] = openstation_notes_derive_title( $text ); } if ( null !== $request['public'] ) { $update['post_status'] = rest_sanitize_boolean( $request['public'] ) ? 'publish' : 'private'; } if ( null !== $request['color'] ) { update_post_meta( $post->ID, '_wpd_note_color', openstation_notes_sanitize_color( $request['color'] ) ); } if ( null !== $request['x'] ) { update_post_meta( $post->ID, '_wpd_note_x', openstation_notes_sanitize_fraction( $request['x'] ) ); } if ( null !== $request['y'] ) { update_post_meta( $post->ID, '_wpd_note_y', openstation_notes_sanitize_fraction( $request['y'] ) ); } if ( null !== $request['z'] ) { update_post_meta( $post->ID, '_wpd_note_z', absint( $request['z'] ) ); } // Always run the post update — even a meta-only PATCH must bump // `post_modified` so the concurrency token advances and the // Heartbeat delta query sees the move. $result = wp_update_post( $update, true ); if ( is_wp_error( $result ) ) { $result->add_data( array( 'status' => 500 ) ); return $result; } return rest_ensure_response( openstation_notes_prepare( get_post( $post->ID ) ) ); } /** * DELETE /notes/:id — soft-trash, owner only. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function openstation_notes_rest_delete( $request ) { $post = openstation_notes_get_note( $request['id'] ); if ( is_wp_error( $post ) ) { return $post; } $owner = openstation_notes_require_owner( $post ); if ( is_wp_error( $owner ) ) { return $owner; } if ( ! wp_trash_post( $post->ID ) ) { return new WP_Error( 'openstation_notes_trash_failed', __( 'Could not move the note to the trash.', 'desktop-mode' ), array( 'status' => 500 ) ); } return rest_ensure_response( array( 'trashed' => true, 'id' => (int) $post->ID, ) ); } /** * POST /notes/:id/restore — untrash (Undo), owner only. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function openstation_notes_rest_restore( $request ) { $post = openstation_notes_get_note( $request['id'], true ); if ( is_wp_error( $post ) ) { return $post; } $owner = openstation_notes_require_owner( $post ); if ( is_wp_error( $owner ) ) { return $owner; } if ( 'trash' !== $post->post_status ) { return rest_ensure_response( openstation_notes_prepare( $post ) ); } if ( ! wp_untrash_post( $post->ID ) ) { return new WP_Error( 'openstation_notes_restore_failed', __( 'Could not restore the note.', 'desktop-mode' ), array( 'status' => 500 ) ); } // If this note was trashed by a "convert to post" action, undoing // the conversion must also discard the draft it spawned — otherwise // Undo would leave the note back on the wall AND a stray draft. The // link is written by the convert route (`_wpd_note_converted_post`) // and consumed once here. Only a still-present draft is trashed; a // draft the user already published or trashed themselves is left be. $converted_post_id = (int) get_post_meta( $post->ID, '_wpd_note_converted_post', true ); if ( $converted_post_id > 0 ) { delete_post_meta( $post->ID, '_wpd_note_converted_post' ); $draft = get_post( $converted_post_id ); if ( $draft instanceof WP_Post && 'draft' === $draft->post_status ) { wp_trash_post( $converted_post_id ); } } return rest_ensure_response( openstation_notes_prepare( get_post( $post->ID ) ) ); } /** * Convert a note's plain text into Gutenberg paragraph-block markup. * * Blank lines split paragraphs; single newlines within a paragraph * become `
`. The result lands clean in the block editor rather * than as one classic-HTML blob. * * @param string $text Note text. * @return string Serialized block markup (empty string for empty text). */ function openstation_notes_text_to_blocks( $text ) { $text = str_replace( array( "\r\n", "\r" ), "\n", (string) $text ); $paragraphs = preg_split( '/\n{2,}/', trim( $text ) ); $blocks = array(); foreach ( $paragraphs as $paragraph ) { $paragraph = trim( $paragraph, "\n" ); if ( '' === $paragraph ) { continue; } $html = nl2br( esc_html( $paragraph ), false ); $blocks[] = "\n

{$html}

\n"; } return implode( "\n\n", $blocks ); } /** * POST /notes/:id/convert — spawn a draft post from a note, then trash * the note. Owner only, and the owner must be able to author posts. * * The note is trashed (not hard-deleted) and linked to its new draft * via `_wpd_note_converted_post` so the standard restore route can undo * both sides of the conversion (see `openstation_notes_rest_restore`). * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function openstation_notes_rest_convert( $request ) { $post = openstation_notes_get_note( $request['id'] ); if ( is_wp_error( $post ) ) { return $post; } $owner = openstation_notes_require_owner( $post ); if ( is_wp_error( $owner ) ) { return $owner; } if ( ! current_user_can( 'edit_posts' ) ) { return new WP_Error( 'openstation_notes_cannot_create_posts', __( 'You are not allowed to create posts.', 'desktop-mode' ), array( 'status' => 403 ) ); } $text = (string) get_post_field( 'post_content', $post, 'raw' ); $title = openstation_notes_derive_title( $text ); /** * Filters the arguments used to create the draft post from a note. * * Hook here to change the post type/status, assign a category or * author, or wrap the body in different block markup. * * @param array $post_args Args passed to `wp_insert_post()`. * @param WP_Post $post The source note. * @param WP_REST_Request $request The convert request. */ $post_args = apply_filters( 'openstation_notes_convert_post_args', array( 'post_type' => 'post', 'post_status' => 'draft', 'post_author' => (int) $post->post_author, 'post_title' => $title, 'post_content' => openstation_notes_text_to_blocks( $text ), ), $post, $request ); $new_post_id = wp_insert_post( $post_args, true ); if ( is_wp_error( $new_post_id ) ) { $new_post_id->add_data( array( 'status' => 500 ) ); return $new_post_id; } // Link the note to its draft BEFORE trashing so restore can reverse // both sides. If the trash fails, roll the draft back so a failed // conversion never leaves an orphan draft behind. update_post_meta( $post->ID, '_wpd_note_converted_post', (int) $new_post_id ); if ( ! wp_trash_post( $post->ID ) ) { wp_delete_post( $new_post_id, true ); delete_post_meta( $post->ID, '_wpd_note_converted_post' ); return new WP_Error( 'openstation_notes_convert_failed', __( 'Could not convert the note to a post.', 'desktop-mode' ), array( 'status' => 500 ) ); } /** * Fires after a note has been converted to a draft post. * * @param int $new_post_id The draft post id. * @param WP_Post $post The source note (now trashed). * @param WP_REST_Request $request The convert request. */ do_action( 'openstation_notes_converted', (int) $new_post_id, $post, $request ); return rest_ensure_response( array( 'noteId' => (int) $post->ID, 'postId' => (int) $new_post_id, 'editUrl' => (string) get_edit_post_link( $new_post_id, 'raw' ), ) ); } /** * Whether the current user's desktop would show any pinned notes. * * The presence hint the boot config ships as `hasNotes`: the notes * bundle is presence-gated client-side (`src/notes/sentinel.ts`), so * a user with no notes never downloads it — and never fires the * boot-time list request the layer used to make unconditionally. * * **Steady-state cost: zero queries.** The computed answer is cached * in user meta, stamped with a revision the site bumps whenever any * note changes (`openstation_notes_bump_rev()` below). The revision * lives in an autoloaded option and the user's meta cache is already * primed on every admin request, so a boot between note changes * reads two warm caches and touches the database not at all. Only * the first boot after a note is created / deleted / re-scoped runs * the probes again — two `fields => ids`, one-row queries at most: * anyone's public note first, the user's own private ones second, * mirroring the visibility rule the list route enforces. * * @return bool */ function openstation_notes_user_has_any() { $rev = (string) get_option( 'desktop_mode_notes_rev', '0' ); $user_id = get_current_user_id(); $cached = (string) get_user_meta( $user_id, '_desktop_mode_has_notes', true ); if ( '' !== $cached ) { list( $cached_rev, $cached_value ) = array_pad( explode( ':', $cached, 2 ), 2, '' ); if ( $cached_rev === $rev ) { return '1' === $cached_value; } } $public = new WP_Query( array( 'post_type' => OPENSTATION_NOTES_POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, ) ); $has = (bool) $public->posts; if ( ! $has ) { $own = new WP_Query( array( 'post_type' => OPENSTATION_NOTES_POST_TYPE, 'post_status' => 'private', 'author' => $user_id, 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, ) ); $has = (bool) $own->posts; } update_user_meta( $user_id, '_desktop_mode_has_notes', $rev . ':' . ( $has ? '1' : '0' ) ); return $has; } /** * Invalidate every user's cached `hasNotes` answer. * * One autoloaded revision counter instead of per-user cache deletes: * a public note's existence changes the answer for EVERY user, and * enumerating users to clear meta would be the expensive thing this * cache exists to avoid. Bumping the rev makes all stamped answers * stale at the cost of one option write per note change — and note * changes are rare next to boots. * * @param int|WP_Post $post Post id or object being changed. * @return void */ function openstation_notes_bump_rev( $post ) { $post = get_post( $post ); if ( ! $post instanceof WP_Post || OPENSTATION_NOTES_POST_TYPE !== $post->post_type ) { return; } update_option( 'desktop_mode_notes_rev', (string) time() . '.' . wp_rand( 0, 999 ), true ); } /** * Every path a note can change through: status moves (create, * publish/private flips, trash, restore) fire * `transition_post_status`; hard deletes fire `deleted_post`. * * @param string $new_status New status. * @param string $old_status Old status. * @param WP_Post $post The post. * @return void */ function openstation_notes_bump_rev_on_transition( $new_status, $old_status, $post ) { if ( $new_status === $old_status ) { return; } openstation_notes_bump_rev( $post ); } add_action( 'transition_post_status', 'openstation_notes_bump_rev_on_transition', 10, 3 ); add_action( 'deleted_post', 'openstation_notes_bump_rev' );