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

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

570 lines 20.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 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