| 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 new WP_Error( |
| 339 |
'openstation_ai_failed', |
| 340 |
$json->get_error_message(), |
| 341 |
array( 'status' => 502 ) |
| 342 |
); |
| 343 |
} |
| 344 |
|
| 345 |
$data = json_decode( (string) $json, true ); |
| 346 |
if ( ! is_array( $data ) ) { |
| 347 |
return new WP_Error( |
| 348 |
'openstation_ai_parse', |
| 349 |
__( 'The AI response could not be parsed.', 'desktop-mode' ), |
| 350 |
array( 'status' => 502 ) |
| 351 |
); |
| 352 |
} |
| 353 |
|
| 354 |
$readiness = isset( $data['readiness'] ) && is_array( $data['readiness'] ) ? $data['readiness'] : array(); |
| 355 |
|
| 356 |
$suggestions = array( |
| 357 |
'titles' => openstation_drafts_clean_list( isset( $data['titles'] ) ? $data['titles'] : array(), 5 ), |
| 358 |
'excerpt' => trim( wp_strip_all_tags( (string) ( isset( $data['excerpt'] ) ? $data['excerpt'] : '' ) ) ), |
| 359 |
'tags' => openstation_drafts_clean_list( isset( $data['tags'] ) ? $data['tags'] : array(), 8 ), |
| 360 |
'categories' => openstation_drafts_clean_list( isset( $data['categories'] ) ? $data['categories'] : array(), 5 ), |
| 361 |
'readiness' => array( |
| 362 |
'summary' => trim( wp_strip_all_tags( (string) ( isset( $readiness['summary'] ) ? $readiness['summary'] : '' ) ) ), |
| 363 |
'missing' => openstation_drafts_clean_list( isset( $readiness['missing'] ) ? $readiness['missing'] : array(), 5 ), |
| 364 |
), |
| 365 |
); |
| 366 |
|
| 367 |
/** |
| 368 |
* Filters the normalized suggestions before they reach the widget. |
| 369 |
* |
| 370 |
* Runs after tag-stripping and truncation, so a listener can drop, |
| 371 |
* reorder or append entries without re-sanitizing. |
| 372 |
* |
| 373 |
* @param array $suggestions { titles, excerpt, tags, categories, readiness }. |
| 374 |
* @param WP_Post $post The draft the suggestions describe. |
| 375 |
*/ |
| 376 |
$suggestions = (array) apply_filters( 'openstation_drafts_ai_suggestions', $suggestions, $post ); |
| 377 |
|
| 378 |
return new WP_REST_Response( $suggestions, 200 ); |
| 379 |
} |
| 380 |
|
| 381 |
/** |
| 382 |
* Trim, tag-strip and cap a list of model-supplied strings. |
| 383 |
* |
| 384 |
* @param mixed $list Raw list from the model. |
| 385 |
* @param int $max Maximum entries to keep. |
| 386 |
* @return string[] |
| 387 |
*/ |
| 388 |
function openstation_drafts_clean_list( $list, $max ) { |
| 389 |
$out = array(); |
| 390 |
foreach ( (array) $list as $item ) { |
| 391 |
if ( ! is_scalar( $item ) ) { |
| 392 |
continue; |
| 393 |
} |
| 394 |
$item = trim( wp_strip_all_tags( (string) $item ) ); |
| 395 |
if ( '' !== $item ) { |
| 396 |
$out[] = $item; |
| 397 |
} |
| 398 |
} |
| 399 |
return array_slice( $out, 0, (int) $max ); |
| 400 |
} |
| 401 |
|
| 402 |
/** |
| 403 |
* Register the "apply a suggestion to the draft" REST route. |
| 404 |
* |
| 405 |
* POST desktop-mode/v1/draft-apply { post_id, title?, excerpt?, tags?, categories? } |
| 406 |
* writes the chosen suggestion straight onto the draft, so the user can |
| 407 |
* accept a title / excerpt / tag / category from the widget without |
| 408 |
* opening the editor. New categories are only created for users who can |
| 409 |
* manage categories; otherwise unknown categories are skipped. Not |
| 410 |
* AI-gated — this is a plain edit of the user's own draft. |
| 411 |
* |
| 412 |
* @return void |
| 413 |
*/ |
| 414 |
function openstation_register_drafts_apply_route() { |
| 415 |
register_rest_route( |
| 416 |
'desktop-mode/v1', |
| 417 |
'/draft-apply', |
| 418 |
array( |
| 419 |
'methods' => WP_REST_Server::CREATABLE, |
| 420 |
'callback' => 'openstation_rest_draft_apply', |
| 421 |
'permission_callback' => 'openstation_rest_draft_apply_permission', |
| 422 |
'args' => array( |
| 423 |
'post_id' => array( |
| 424 |
'required' => true, |
| 425 |
'type' => 'integer', |
| 426 |
'sanitize_callback' => 'absint', |
| 427 |
), |
| 428 |
'title' => array( 'type' => 'string' ), |
| 429 |
'excerpt' => array( 'type' => 'string' ), |
| 430 |
'tags' => array( |
| 431 |
'type' => 'array', |
| 432 |
'items' => array( 'type' => 'string' ), |
| 433 |
), |
| 434 |
'categories' => array( |
| 435 |
'type' => 'array', |
| 436 |
'items' => array( 'type' => 'string' ), |
| 437 |
), |
| 438 |
), |
| 439 |
) |
| 440 |
); |
| 441 |
} |
| 442 |
add_action( 'rest_api_init', 'openstation_register_drafts_apply_route' ); |
| 443 |
|
| 444 |
/** |
| 445 |
* Permission gate: the user can edit the target post. |
| 446 |
* |
| 447 |
* @param WP_REST_Request $request Request. |
| 448 |
* @return true|WP_Error |
| 449 |
*/ |
| 450 |
function openstation_rest_draft_apply_permission( WP_REST_Request $request ) { |
| 451 |
$post_id = absint( $request['post_id'] ); |
| 452 |
if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) { |
| 453 |
return new WP_Error( |
| 454 |
'rest_forbidden', |
| 455 |
__( 'You are not allowed to edit this post.', 'desktop-mode' ), |
| 456 |
array( 'status' => rest_authorization_required_code() ) |
| 457 |
); |
| 458 |
} |
| 459 |
return true; |
| 460 |
} |
| 461 |
|
| 462 |
/** |
| 463 |
* Apply a title / excerpt / tag / category suggestion to a draft. |
| 464 |
* |
| 465 |
* @param WP_REST_Request $request Request. |
| 466 |
* @return WP_REST_Response|WP_Error |
| 467 |
*/ |
| 468 |
function openstation_rest_draft_apply( WP_REST_Request $request ) { |
| 469 |
$post_id = absint( $request['post_id'] ); |
| 470 |
$post = get_post( $post_id ); |
| 471 |
if ( ! $post instanceof WP_Post ) { |
| 472 |
return new WP_Error( |
| 473 |
'rest_post_invalid', |
| 474 |
__( 'Post not found.', 'desktop-mode' ), |
| 475 |
array( 'status' => 404 ) |
| 476 |
); |
| 477 |
} |
| 478 |
|
| 479 |
$applied = array(); |
| 480 |
$update = array( 'ID' => $post_id ); |
| 481 |
|
| 482 |
if ( $request->has_param( 'title' ) ) { |
| 483 |
$title = sanitize_text_field( (string) $request['title'] ); |
| 484 |
if ( '' !== $title ) { |
| 485 |
$update['post_title'] = $title; |
| 486 |
$applied['title'] = $title; |
| 487 |
} |
| 488 |
} |
| 489 |
if ( $request->has_param( 'excerpt' ) ) { |
| 490 |
$excerpt = sanitize_textarea_field( (string) $request['excerpt'] ); |
| 491 |
$update['post_excerpt'] = $excerpt; |
| 492 |
$applied['excerpt'] = $excerpt; |
| 493 |
} |
| 494 |
|
| 495 |
if ( count( $update ) > 1 ) { |
| 496 |
$result = wp_update_post( $update, true ); |
| 497 |
if ( is_wp_error( $result ) ) { |
| 498 |
return new WP_Error( |
| 499 |
'openstation_apply_failed', |
| 500 |
$result->get_error_message(), |
| 501 |
array( 'status' => 500 ) |
| 502 |
); |
| 503 |
} |
| 504 |
} |
| 505 |
|
| 506 |
$tags = $request['tags']; |
| 507 |
if ( is_array( $tags ) && ! empty( $tags ) ) { |
| 508 |
$clean = array(); |
| 509 |
foreach ( $tags as $tag ) { |
| 510 |
$tag = sanitize_text_field( (string) $tag ); |
| 511 |
if ( '' !== $tag ) { |
| 512 |
$clean[] = $tag; |
| 513 |
} |
| 514 |
} |
| 515 |
if ( ! empty( $clean ) ) { |
| 516 |
// Append (true) — never clobber existing tags. Creates terms as needed. |
| 517 |
wp_set_post_tags( $post_id, $clean, true ); |
| 518 |
$applied['tags'] = $clean; |
| 519 |
} |
| 520 |
} |
| 521 |
|
| 522 |
$categories = $request['categories']; |
| 523 |
if ( is_array( $categories ) && ! empty( $categories ) ) { |
| 524 |
$cat_ids = array(); |
| 525 |
$assigned = array(); |
| 526 |
$can_create = current_user_can( 'manage_categories' ); |
| 527 |
foreach ( $categories as $cat ) { |
| 528 |
$cat = sanitize_text_field( (string) $cat ); |
| 529 |
if ( '' === $cat ) { |
| 530 |
continue; |
| 531 |
} |
| 532 |
$term = get_term_by( 'name', $cat, 'category' ); |
| 533 |
if ( $term instanceof WP_Term ) { |
| 534 |
$cat_ids[] = (int) $term->term_id; |
| 535 |
$assigned[] = $cat; |
| 536 |
} elseif ( $can_create ) { |
| 537 |
// Only users who can manage categories may create new ones — |
| 538 |
// mirrors Core, where Authors can assign but not create. |
| 539 |
$new = wp_insert_term( $cat, 'category' ); |
| 540 |
if ( ! is_wp_error( $new ) && isset( $new['term_id'] ) ) { |
| 541 |
$cat_ids[] = (int) $new['term_id']; |
| 542 |
$assigned[] = $cat; |
| 543 |
} |
| 544 |
} |
| 545 |
// Otherwise the category doesn't exist and the user can't create |
| 546 |
// it — skip it silently rather than assigning nothing. |
| 547 |
} |
| 548 |
if ( ! empty( $cat_ids ) ) { |
| 549 |
// Append (true) — keep any categories already on the post. |
| 550 |
wp_set_post_categories( $post_id, $cat_ids, true ); |
| 551 |
$applied['categories'] = $assigned; |
| 552 |
} |
| 553 |
} |
| 554 |
|
| 555 |
/** |
| 556 |
* Fires after a draft suggestion has been written onto a post. |
| 557 |
* |
| 558 |
* `$applied` holds only the fields that actually changed — an empty |
| 559 |
* array means the request was a no-op (e.g. an unknown category the |
| 560 |
* user could not create). |
| 561 |
* |
| 562 |
* @param int $post_id Post that was updated. |
| 563 |
* @param array $applied Fields written: { title?, excerpt?, tags?, categories? }. |
| 564 |
* @param WP_Post $post The post as it was before the update. |
| 565 |
*/ |
| 566 |
do_action( 'openstation_drafts_suggestion_applied', $post_id, $applied, $post ); |
| 567 |
|
| 568 |
return new WP_REST_Response( array( 'applied' => $applied ), 200 ); |
| 569 |
} |
| 570 |
|