PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 1.1.10 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 All 37 releases
desktop-mode / includes / widgets / widget-drafts.php

widget-drafts.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.13, at includes/widgets/widget-drafts.php

635 lines 22.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Drafts Widget.
4 *
5 * A quick list of the current user's most recently edited draft posts,
6 * each a click away from reopening in the editor (the shell's admin-link
7 * interceptor turns the row into a native window).
8 *
9 * Data source: WordPress REST API /wp/v2/posts?status=draft (edit
10 * context, scoped to the viewer with `author` — without it an editor
11 * or admin would see every draft on the site, not their own).
12 * Refresh: every 60 seconds while the tab is visible, plus an
13 * immediate refresh on every `os.post.changed` broadcast (a block-editor
14 * save, a relayed content change) and when a window closes or blurs.
15 * Requires: OpenStation 0.18.0+ (openstation_register_widget).
16 *
17 * @package OpenStation
18 */
19
20 defined( 'ABSPATH' ) || exit;
21
22 /**
23 * Register the JS + CSS assets.
24 */
25 function openstation_register_drafts_widget_assets() {
26 $suffix = openstation_asset_suffix();
27 $version = defined( 'OPENSTATION_VERSION' ) ? OPENSTATION_VERSION : '0';
28
29 $js_path = OPENSTATION_DIR . 'assets/js/widget-drafts' . $suffix . '.js';
30 $css_path = OPENSTATION_DIR . 'assets/js/widget-drafts' . $suffix . '.css';
31
32 wp_register_style(
33 'os-drafts-widget',
34 OPENSTATION_URL . 'assets/js/widget-drafts' . $suffix . '.css',
35 array(),
36 file_exists( $css_path ) ? (string) filemtime( $css_path ) : $version
37 );
38
39 wp_register_script(
40 'os-drafts-widget',
41 OPENSTATION_URL . 'assets/js/widget-drafts' . $suffix . '.js',
42 array( 'wp-api-fetch' ),
43 file_exists( $js_path ) ? (string) filemtime( $js_path ) : $version,
44 true
45 );
46 }
47 add_action( 'init', 'openstation_register_drafts_widget_assets', 5 );
48
49 /**
50 * Eagerly enqueue the CSS on shell pages so there is no flash of
51 * unstyled content while the lazy JS bundle loads.
52 */
53 function openstation_enqueue_drafts_widget_styles() {
54 if ( function_exists( 'openstation_is_enabled' ) && ! openstation_is_enabled() ) {
55 return;
56 }
57 if ( function_exists( 'openstation_is_chromeless_request' ) && openstation_is_chromeless_request() ) {
58 return;
59 }
60 wp_enqueue_style( 'os-drafts-widget' );
61 }
62 add_action( 'admin_enqueue_scripts', 'openstation_enqueue_drafts_widget_styles', 20 );
63
64 /**
65 * Register the widget definition.
66 *
67 * @return true|WP_Error True on success, `WP_Error` when the registry
68 * rejects the entry (e.g. the viewer lacks
69 * `edit_posts`), false if the registry is absent.
70 */
71 function openstation_register_drafts_widget() {
72 if ( ! function_exists( 'openstation_register_widget' ) ) {
73 return false;
74 }
75 return openstation_register_widget(
76 'desktop-mode/drafts',
77 array(
78 'label' => __( 'Drafts', 'desktop-mode' ),
79 'description' => __( 'Your unfinished posts — click to reopen in the editor.', 'desktop-mode' ),
80 'icon' => 'dashicons-edit',
81 'script' => 'os-drafts-widget',
82 'movable' => true,
83 'resizable' => true,
84 'min_width' => 240,
85 'min_height' => 180,
86 'default_width' => 300,
87 'default_height' => 320,
88 // The REST query behind the widget needs `edit_posts`. Without
89 // the gate a subscriber can add it from the picker and only
90 // ever sees the error state.
91 'capabilities' => array( 'edit_posts' ),
92 )
93 );
94 }
95 add_action( 'init', 'openstation_register_drafts_widget', 6 );
96
97
98 /**
99 * Register the AI writing-suggestions REST route.
100 *
101 * POST desktop-mode/v1/draft-suggestions { post_id }
102 * → { titles, excerpt, tags, categories, readiness: { summary, missing } }
103 *
104 * Read-only: it reads the draft and returns AI suggestions; it never writes
105 * back to the post. Writing an accepted suggestion is a separate, explicit
106 * call to `/draft-apply` below.
107 *
108 * Gated on the user being able to edit the post AND an AI provider being
109 * configured (Settings → Connectors). The capability check runs first so an
110 * unauthorized caller can't probe whether the site has AI set up. The 💡
111 * button that calls this is hidden unless AI is available, so the provider
112 * gate here is defence in depth.
113 *
114 * @return void
115 */
116 function openstation_register_drafts_ai_routes() {
117 register_rest_route(
118 'desktop-mode/v1',
119 '/draft-suggestions',
120 array(
121 'methods' => WP_REST_Server::CREATABLE,
122 'callback' => 'openstation_rest_draft_suggestions',
123 'permission_callback' => 'openstation_rest_draft_suggestions_permission',
124 'args' => array(
125 'post_id' => array(
126 'required' => true,
127 'type' => 'integer',
128 'sanitize_callback' => 'absint',
129 ),
130 ),
131 )
132 );
133 }
134 add_action( 'rest_api_init', 'openstation_register_drafts_ai_routes' );
135
136 /**
137 * Permission gate: the user can edit the target post, and AI is configured.
138 *
139 * Capability first, provider second — an unauthorized caller gets the same
140 * 403 whether or not the site has a provider, so the response can't be used
141 * to fingerprint the site's AI setup.
142 *
143 * @param WP_REST_Request $request Request.
144 * @return true|WP_Error
145 */
146 function openstation_rest_draft_suggestions_permission( WP_REST_Request $request ) {
147 $post_id = absint( $request['post_id'] );
148 if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) {
149 return new WP_Error(
150 'rest_forbidden',
151 __( 'You are not allowed to edit this post.', 'desktop-mode' ),
152 array( 'status' => rest_authorization_required_code() )
153 );
154 }
155 if ( ! function_exists( 'openstation_ai_provider_configured' ) || ! openstation_ai_provider_configured() ) {
156 return new WP_Error(
157 'openstation_ai_unavailable',
158 __( 'No AI provider is configured.', 'desktop-mode' ),
159 array( 'status' => 503 )
160 );
161 }
162 return true;
163 }
164
165 /**
166 * The system instruction used for draft suggestions.
167 *
168 * Split out so the prompt is filterable without copying the whole route.
169 *
170 * @param WP_Post $post The draft being described.
171 * @return string
172 */
173 function openstation_drafts_ai_instructions( WP_Post $post ) {
174 $instructions = 'You are a writing assistant for a WordPress author. Given a draft post\'s current title and content, help them finish and file it. Provide: exactly 3 concise, compelling title options (about 70 characters max each); one 1-2 sentence excerpt suitable as the post summary; 3 to 6 lowercase topical tags; 1 to 2 categories (prefer the site\'s existing categories, listed in the message; propose a new concise name only if none fit); and a readiness check.
175
176 The readiness check judges structure and completeness only: does the draft have a clear introduction, enough substance, at least one concrete example or detail, and a conclusion? The "missing" array lists only what is absent from the text you were given. Mention a typo, misspelling or cut-off sentence only together with the exact text, quoted from the draft. When the draft has an introduction, a body with a concrete detail and a conclusion, return an empty "missing" array and say in the summary that it looks ready.
177
178 Write everything in the same language as the draft. Do not invent facts that are not supported by the content.';
179
180 /**
181 * Filters the system instruction sent with a draft-suggestions request.
182 *
183 * @param string $instructions System instruction text.
184 * @param WP_Post $post The draft being described.
185 */
186 return (string) apply_filters( 'openstation_drafts_ai_instructions', $instructions, $post );
187 }
188
189 /**
190 * JSON schema the model must answer in for draft suggestions.
191 *
192 * @param WP_Post $post The draft being described.
193 * @return array
194 */
195 function openstation_drafts_ai_schema( WP_Post $post ) {
196 $schema = array(
197 'type' => 'object',
198 'additionalProperties' => false,
199 'required' => array( 'titles', 'excerpt', 'tags', 'categories', 'readiness' ),
200 'properties' => array(
201 'titles' => array(
202 'type' => 'array',
203 'items' => array( 'type' => 'string' ),
204 'description' => 'Exactly 3 alternative title suggestions, each about 70 characters or fewer.',
205 ),
206 'excerpt' => array(
207 'type' => 'string',
208 'description' => 'A single 1-2 sentence excerpt/summary for the post.',
209 ),
210 'tags' => array(
211 'type' => 'array',
212 'items' => array( 'type' => 'string' ),
213 'description' => '3 to 6 lowercase topical tags.',
214 ),
215 'categories' => array(
216 'type' => 'array',
217 'items' => array( 'type' => 'string' ),
218 'description' => '1 to 2 category names. Strongly prefer the existing site categories listed in the prompt; only propose a new concise name if none fit.',
219 ),
220 'readiness' => array(
221 'type' => 'object',
222 'additionalProperties' => false,
223 'required' => array( 'summary', 'missing' ),
224 'properties' => array(
225 'summary' => array(
226 'type' => 'string',
227 'description' => 'One short sentence on how close the draft is to being publishable, including a rough sense of its length/completeness.',
228 ),
229 'missing' => array(
230 'type' => 'array',
231 'items' => array( 'type' => 'string' ),
232 'description' => '0 to 4 short, concrete things the draft genuinely still needs, judged only on structure/completeness (e.g. "a conclusion", "a clearer intro", "at least one concrete example", "more depth on X"). Only list what is truly absent from the provided text. Never invent typos or cut-off sentences; any wording problem you cite must be an exact verbatim quote from the draft. Return an empty array when the draft already reads as complete.',
233 ),
234 ),
235 ),
236 ),
237 );
238
239 /**
240 * Filters the JSON schema the model answers draft-suggestion requests in.
241 *
242 * Changing the shape here changes the REST response shape too — the route
243 * only normalizes the keys it knows about.
244 *
245 * @param array $schema JSON schema.
246 * @param WP_Post $post The draft being described.
247 */
248 return (array) apply_filters( 'openstation_drafts_ai_schema', $schema, $post );
249 }
250
251 /**
252 * Build the user-facing prompt body: title, trimmed content, existing terms.
253 *
254 * @param WP_Post $post The draft being described.
255 * @return string
256 */
257 function openstation_drafts_ai_prompt_text( WP_Post $post ) {
258 $title = (string) $post->post_title;
259 $content = trim( (string) preg_replace( '/\s+/', ' ', wp_strip_all_tags( (string) $post->post_content ) ) );
260
261 /**
262 * Filters how many characters of the draft are sent to the model.
263 *
264 * @param int $limit Character limit.
265 * @param WP_Post $post The draft being described.
266 */
267 $limit = (int) apply_filters( 'openstation_drafts_ai_content_limit', 4000, $post );
268
269 // mb_substr so a long draft isn't cut mid-multibyte-character.
270 if ( $limit > 0 && mb_strlen( $content ) > $limit ) {
271 $content = mb_substr( $content, 0, $limit ) . '…';
272 }
273
274 $text = 'Current title: ' . ( '' !== $title ? $title : '(none)' ) . "\n\n";
275 $text .= "Draft content:\n" . ( '' !== $content ? $content : '(empty)' );
276
277 // Give the model the site's existing categories so it classifies into
278 // them rather than inventing a fresh taxonomy.
279 $existing_cats = get_terms(
280 array(
281 'taxonomy' => 'category',
282 'hide_empty' => false,
283 'number' => 40,
284 'fields' => 'names',
285 )
286 );
287 if ( is_array( $existing_cats ) && ! empty( $existing_cats ) ) {
288 $text .= "\n\nExisting categories on this site: " . implode( ', ', $existing_cats ) . '.';
289 }
290
291 return $text;
292 }
293
294 /**
295 * Generate title / excerpt / tag / category suggestions for a draft.
296 *
297 * @param WP_REST_Request $request Request.
298 * @return WP_REST_Response|WP_Error
299 */
300 function openstation_rest_draft_suggestions( WP_REST_Request $request ) {
301 if ( ! function_exists( 'wp_ai_client_prompt' ) ) {
302 return new WP_Error(
303 'openstation_ai_unavailable',
304 __( 'AI is not available on this site.', 'desktop-mode' ),
305 array( 'status' => 503 )
306 );
307 }
308
309 $post = get_post( absint( $request['post_id'] ) );
310 if ( ! $post instanceof WP_Post ) {
311 return new WP_Error(
312 'rest_post_invalid',
313 __( 'Post not found.', 'desktop-mode' ),
314 array( 'status' => 404 )
315 );
316 }
317
318 // `generate_text()` with a JSON schema — same call shape the comment
319 // scorer uses. The SDK can throw as well as return a WP_Error, so both
320 // paths land on the same 502.
321 try {
322 $builder = wp_ai_client_prompt( openstation_drafts_ai_prompt_text( $post ) )
323 ->using_system_instruction( openstation_drafts_ai_instructions( $post ) )
324 ->as_json_response( openstation_ai_normalize_response_schema( openstation_drafts_ai_schema( $post ) ) );
325
326 $json = openstation_ai_apply_model_config(
327 $builder,
328 array(
329 'user_id' => get_current_user_id(),
330 'source' => 'widgets/drafts-suggestions',
331 'has_schema' => true,
332 )
333 )->generate_text();
334 } catch ( \Throwable $e ) {
335 $json = new WP_Error( 'openstation_ai_failed', $e->getMessage() );
336 }
337
338 if ( is_wp_error( $json ) ) {
339 return openstation_drafts_ai_failure( $json );
340 }
341
342 $data = json_decode( (string) $json, true );
343 if ( ! is_array( $data ) ) {
344 return new WP_Error(
345 'openstation_ai_parse',
346 __( 'The AI response could not be parsed.', 'desktop-mode' ),
347 array( 'status' => 502 )
348 );
349 }
350
351 $readiness = isset( $data['readiness'] ) && is_array( $data['readiness'] ) ? $data['readiness'] : array();
352
353 $suggestions = array(
354 'titles' => openstation_drafts_clean_list( isset( $data['titles'] ) ? $data['titles'] : array(), 5 ),
355 'excerpt' => trim( wp_strip_all_tags( (string) ( isset( $data['excerpt'] ) ? $data['excerpt'] : '' ) ) ),
356 'tags' => openstation_drafts_clean_list( isset( $data['tags'] ) ? $data['tags'] : array(), 8 ),
357 'categories' => openstation_drafts_clean_list( isset( $data['categories'] ) ? $data['categories'] : array(), 5 ),
358 'readiness' => array(
359 'summary' => trim( wp_strip_all_tags( (string) ( isset( $readiness['summary'] ) ? $readiness['summary'] : '' ) ) ),
360 'missing' => openstation_drafts_clean_list( isset( $readiness['missing'] ) ? $readiness['missing'] : array(), 5 ),
361 ),
362 );
363
364 /**
365 * Filters the normalized suggestions before they reach the widget.
366 *
367 * Runs after tag-stripping and truncation, so a listener can drop,
368 * reorder or append entries without re-sanitizing.
369 *
370 * @param array $suggestions { titles, excerpt, tags, categories, readiness }.
371 * @param WP_Post $post The draft the suggestions describe.
372 */
373 $suggestions = (array) apply_filters( 'openstation_drafts_ai_suggestions', $suggestions, $post );
374
375 return new WP_REST_Response( $suggestions, 200 );
376 }
377
378 /**
379 * Turn a failed generation into the route's error.
380 *
381 * The route answers 502 for every provider failure: the failure is the
382 * upstream's, not the caller's, and a provider's own 401 or 403 passed
383 * through as the REST status would read as "your WordPress session is
384 * invalid" to every client on the page. What the caller needs in order to
385 * say something useful goes into the error data instead:
386 *
387 * - `reason`: `quota` (out of credits or rate limited), `auth` (the site's
388 * key was rejected), `unavailable` (the provider could not be reached or
389 * answered 5xx) or `other`.
390 * - `provider_status`: the provider's own HTTP status, or null when the
391 * failure never reached the provider.
392 * - `detail`: the provider's message, verbatim, for the console and logs.
393 *
394 * The top-level message says what happened in plain words. The Core AI
395 * Client reports a rejected request as `prompt_client_error` /
396 * `prompt_upstream_server_error` with the provider's status in
397 * `data.status` and a message of the shape "Too Many Requests (429) -
398 * <provider text>", which is why the provider text never reached the
399 * widget as anything but that string.
400 *
401 * @param WP_Error $error Failed generation.
402 * @return WP_Error
403 */
404 function openstation_drafts_ai_failure( WP_Error $error ) {
405 $code = (string) $error->get_error_code();
406 $data = $error->get_error_data();
407 $detail = (string) $error->get_error_message();
408
409 $provider_status = null;
410 if ( in_array( $code, array( 'prompt_client_error', 'prompt_upstream_server_error' ), true )
411 && is_array( $data ) && isset( $data['status'] ) ) {
412 $provider_status = (int) $data['status'];
413 }
414
415 if ( 'prompt_network_error' === $code || ( null !== $provider_status && $provider_status >= 500 ) ) {
416 $reason = 'unavailable';
417 $message = __( 'The AI provider could not be reached. Try again in a moment.', 'desktop-mode' );
418 } elseif ( in_array( $provider_status, array( 402, 429 ), true ) ) {
419 $reason = 'quota';
420 $message = __( 'The AI provider has no credits left or is rate limiting this site. Check its plan and billing, or try again later.', 'desktop-mode' );
421 } elseif ( in_array( $provider_status, array( 401, 403 ), true ) ) {
422 $reason = 'auth';
423 $message = __( 'The AI provider rejected this site’s API key. Check the key in Settings → Connectors.', 'desktop-mode' );
424 } elseif ( null === $provider_status && preg_match( '/quota|credits?\b|billing|rate limit/i', $detail ) ) {
425 // A provider that failed without a status (the SDK threw instead of
426 // answering) can still say it was the account, not the request.
427 $reason = 'quota';
428 $message = __( 'The AI provider has no credits left or is rate limiting this site. Check its plan and billing, or try again later.', 'desktop-mode' );
429 } else {
430 $reason = 'other';
431 $message = __( 'The AI provider could not produce suggestions.', 'desktop-mode' );
432 }
433
434 return new WP_Error(
435 'openstation_ai_failed',
436 $message,
437 array(
438 'status' => 502,
439 'reason' => $reason,
440 'provider_status' => $provider_status,
441 'detail' => $detail,
442 )
443 );
444 }
445
446 /**
447 * Trim, tag-strip and cap a list of model-supplied strings.
448 *
449 * @param mixed $list Raw list from the model.
450 * @param int $max Maximum entries to keep.
451 * @return string[]
452 */
453 function openstation_drafts_clean_list( $list, $max ) {
454 $out = array();
455 foreach ( (array) $list as $item ) {
456 if ( ! is_scalar( $item ) ) {
457 continue;
458 }
459 $item = trim( wp_strip_all_tags( (string) $item ) );
460 if ( '' !== $item ) {
461 $out[] = $item;
462 }
463 }
464 return array_slice( $out, 0, (int) $max );
465 }
466
467 /**
468 * Register the "apply a suggestion to the draft" REST route.
469 *
470 * POST desktop-mode/v1/draft-apply { post_id, title?, excerpt?, tags?, categories? }
471 * writes the chosen suggestion straight onto the draft, so the user can
472 * accept a title / excerpt / tag / category from the widget without
473 * opening the editor. New categories are only created for users who can
474 * manage categories; otherwise unknown categories are skipped. Not
475 * AI-gated — this is a plain edit of the user's own draft.
476 *
477 * @return void
478 */
479 function openstation_register_drafts_apply_route() {
480 register_rest_route(
481 'desktop-mode/v1',
482 '/draft-apply',
483 array(
484 'methods' => WP_REST_Server::CREATABLE,
485 'callback' => 'openstation_rest_draft_apply',
486 'permission_callback' => 'openstation_rest_draft_apply_permission',
487 'args' => array(
488 'post_id' => array(
489 'required' => true,
490 'type' => 'integer',
491 'sanitize_callback' => 'absint',
492 ),
493 'title' => array( 'type' => 'string' ),
494 'excerpt' => array( 'type' => 'string' ),
495 'tags' => array(
496 'type' => 'array',
497 'items' => array( 'type' => 'string' ),
498 ),
499 'categories' => array(
500 'type' => 'array',
501 'items' => array( 'type' => 'string' ),
502 ),
503 ),
504 )
505 );
506 }
507 add_action( 'rest_api_init', 'openstation_register_drafts_apply_route' );
508
509 /**
510 * Permission gate: the user can edit the target post.
511 *
512 * @param WP_REST_Request $request Request.
513 * @return true|WP_Error
514 */
515 function openstation_rest_draft_apply_permission( WP_REST_Request $request ) {
516 $post_id = absint( $request['post_id'] );
517 if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) {
518 return new WP_Error(
519 'rest_forbidden',
520 __( 'You are not allowed to edit this post.', 'desktop-mode' ),
521 array( 'status' => rest_authorization_required_code() )
522 );
523 }
524 return true;
525 }
526
527 /**
528 * Apply a title / excerpt / tag / category suggestion to a draft.
529 *
530 * @param WP_REST_Request $request Request.
531 * @return WP_REST_Response|WP_Error
532 */
533 function openstation_rest_draft_apply( WP_REST_Request $request ) {
534 $post_id = absint( $request['post_id'] );
535 $post = get_post( $post_id );
536 if ( ! $post instanceof WP_Post ) {
537 return new WP_Error(
538 'rest_post_invalid',
539 __( 'Post not found.', 'desktop-mode' ),
540 array( 'status' => 404 )
541 );
542 }
543
544 $applied = array();
545 $update = array( 'ID' => $post_id );
546
547 if ( $request->has_param( 'title' ) ) {
548 $title = sanitize_text_field( (string) $request['title'] );
549 if ( '' !== $title ) {
550 $update['post_title'] = $title;
551 $applied['title'] = $title;
552 }
553 }
554 if ( $request->has_param( 'excerpt' ) ) {
555 $excerpt = sanitize_textarea_field( (string) $request['excerpt'] );
556 $update['post_excerpt'] = $excerpt;
557 $applied['excerpt'] = $excerpt;
558 }
559
560 if ( count( $update ) > 1 ) {
561 $result = wp_update_post( $update, true );
562 if ( is_wp_error( $result ) ) {
563 return new WP_Error(
564 'openstation_apply_failed',
565 $result->get_error_message(),
566 array( 'status' => 500 )
567 );
568 }
569 }
570
571 $tags = $request['tags'];
572 if ( is_array( $tags ) && ! empty( $tags ) ) {
573 $clean = array();
574 foreach ( $tags as $tag ) {
575 $tag = sanitize_text_field( (string) $tag );
576 if ( '' !== $tag ) {
577 $clean[] = $tag;
578 }
579 }
580 if ( ! empty( $clean ) ) {
581 // Append (true) — never clobber existing tags. Creates terms as needed.
582 wp_set_post_tags( $post_id, $clean, true );
583 $applied['tags'] = $clean;
584 }
585 }
586
587 $categories = $request['categories'];
588 if ( is_array( $categories ) && ! empty( $categories ) ) {
589 $cat_ids = array();
590 $assigned = array();
591 $can_create = current_user_can( 'manage_categories' );
592 foreach ( $categories as $cat ) {
593 $cat = sanitize_text_field( (string) $cat );
594 if ( '' === $cat ) {
595 continue;
596 }
597 $term = get_term_by( 'name', $cat, 'category' );
598 if ( $term instanceof WP_Term ) {
599 $cat_ids[] = (int) $term->term_id;
600 $assigned[] = $cat;
601 } elseif ( $can_create ) {
602 // Only users who can manage categories may create new ones —
603 // mirrors Core, where Authors can assign but not create.
604 $new = wp_insert_term( $cat, 'category' );
605 if ( ! is_wp_error( $new ) && isset( $new['term_id'] ) ) {
606 $cat_ids[] = (int) $new['term_id'];
607 $assigned[] = $cat;
608 }
609 }
610 // Otherwise the category doesn't exist and the user can't create
611 // it — skip it silently rather than assigning nothing.
612 }
613 if ( ! empty( $cat_ids ) ) {
614 // Append (true) — keep any categories already on the post.
615 wp_set_post_categories( $post_id, $cat_ids, true );
616 $applied['categories'] = $assigned;
617 }
618 }
619
620 /**
621 * Fires after a draft suggestion has been written onto a post.
622 *
623 * `$applied` holds only the fields that actually changed — an empty
624 * array means the request was a no-op (e.g. an unknown category the
625 * user could not create).
626 *
627 * @param int $post_id Post that was updated.
628 * @param array $applied Fields written: { title?, excerpt?, tags?, categories? }.
629 * @param WP_Post $post The post as it was before the update.
630 */
631 do_action( 'openstation_drafts_suggestion_applied', $post_id, $applied, $post );
632
633 return new WP_REST_Response( array( 'applied' => $applied ), 200 );
634 }
635