PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.0.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.0.1
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 0.8.7 All 34 releases
desktop-mode / includes / widgets / widget-drafts.php

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

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