PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.11
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.11
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 0.8.9 0.8.8 All 35 releases
desktop-mode / includes / widgets / widget-drafts.php

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

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