PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.0
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.5.2 All 199 releases
betterdocs / includes / REST / WriteWithAI.php

WriteWithAI.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.0, at includes/REST/WriteWithAI.php

866 lines 43.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPDeveloper\BetterDocs\REST;
4
5 use WP_REST_Request;
6 use WPDeveloper\BetterDocs\Core\BaseAPI;
7 use WPDeveloper\BetterDocs\Utils\AIUsage;
8
9 /**
10 * REST surface for the redesigned "Write with AI" modal.
11 *
12 * Replaces the legacy admin-ajax `generate_openai_content` handler. Supports the
13 * superset modal's flows: full-doc generation, outline generation, expanding an
14 * approved outline into a doc, and generating a doc from pasted source content.
15 * All generation reuses the WriteWithAI service (same model/key/token settings).
16 *
17 * @see \WPDeveloper\BetterDocs\REST\AIEdit Sibling endpoint this mirrors.
18 */
19 class WriteWithAI extends BaseAPI {
20
21 const MAX_SOURCE_LENGTH = 12000;
22 const MAX_PROMPT_LENGTH = 4000;
23
24 // "From Attachment" source: server-side text extraction from an uploaded file.
25 // 5 MB is generous for text/markdown/DOCX while capping abuse; the extracted
26 // text is still clipped to MAX_SOURCE_LENGTH before it reaches the model.
27 const MAX_UPLOAD_BYTES = 5242880; // 5 MB
28
29 public function register() {
30 $this->post(
31 '/write-with-ai',
32 array( $this, 'generate' ),
33 array(
34 'post_id' => array(
35 'type' => 'integer',
36 'required' => false,
37 'default' => 0,
38 ),
39 'action' => array(
40 'type' => 'string',
41 'required' => true,
42 ),
43 'title' => array(
44 'type' => 'string',
45 'required' => false,
46 'default' => '',
47 ),
48 'keywords' => array(
49 'type' => 'string',
50 'required' => false,
51 'default' => '',
52 ),
53 'prompt' => array(
54 'type' => 'string',
55 'required' => false,
56 'default' => '',
57 ),
58 'source' => array(
59 'type' => 'string',
60 'required' => false,
61 'default' => '',
62 ),
63 'source_type' => array(
64 'type' => 'string',
65 'required' => false,
66 'default' => '',
67 ),
68 'git_url' => array(
69 'type' => 'string',
70 'required' => false,
71 'default' => '',
72 ),
73 'git_action' => array(
74 'type' => 'string',
75 'required' => false,
76 'default' => '',
77 ),
78 // "Browse repository" picker params (git-repos / git-items / git-contents).
79 'repo' => array(
80 'type' => 'string',
81 'required' => false,
82 'default' => '',
83 ),
84 'kind' => array(
85 'type' => 'string',
86 'required' => false,
87 'default' => '',
88 ),
89 'path' => array(
90 'type' => 'string',
91 'required' => false,
92 'default' => '',
93 ),
94 'ref' => array(
95 'type' => 'string',
96 'required' => false,
97 'default' => '',
98 ),
99 'outline' => array(
100 'type' => 'array',
101 'required' => false,
102 'default' => array(),
103 ),
104 'tone' => array(
105 'type' => 'string',
106 'required' => false,
107 'default' => '',
108 ),
109 'doc_size' => array(
110 'type' => 'string',
111 'required' => false,
112 'default' => 'any',
113 ),
114 'generate_title' => array(
115 'type' => 'boolean',
116 'required' => false,
117 'default' => false,
118 ),
119 'instruction_ids' => array(
120 'type' => 'array',
121 'required' => false,
122 'default' => array(),
123 ),
124 )
125 );
126 }
127
128 public function permission_check() {
129 // Gate on edit_others_posts to match the sibling FAQ/Glossary AI endpoints
130 // (AIFaq/AIGlossary) and keep Author-role users from spending the AI budget.
131 return current_user_can( 'edit_others_posts' );
132 }
133
134 public function generate( WP_REST_Request $request ) {
135 $write_ai = betterdocs()->ai_autowrtie;
136
137 if ( empty( $write_ai ) || ! $write_ai->isEnabledWriteWithAI() ) {
138 return $this->error(
139 'ai_disabled',
140 __( 'Write with AI is disabled. Enable it from BetterDocs settings.', 'betterdocs' ),
141 400
142 );
143 }
144
145 if ( empty( $write_ai->get_api_key() ) ) {
146 return $this->error(
147 'missing_key',
148 __( 'OpenAI API key is missing. Add one in BetterDocs settings.', 'betterdocs' ),
149 400
150 );
151 }
152
153 $action = sanitize_key( (string) $request->get_param( 'action' ) );
154 $post_id = (int) $request->get_param( 'post_id' );
155 // NOTE: this text is sent verbatim in the OpenAI request body, not rendered
156 // as HTML, so we must NOT strip tags. sanitize_textarea_field() runs
157 // wp_strip_all_tags(), which would delete <ProductCard>, Array<T>, JSX/XML/HTML
158 // from the prompt before the model ever sees it. wp_check_invalid_utf8() keeps
159 // the angle brackets while still guarding against malformed UTF-8.
160 $prompt = $this->clip( wp_check_invalid_utf8( (string) $request->get_param( 'prompt' ) ), self::MAX_PROMPT_LENGTH );
161 $keywords = sanitize_text_field( (string) $request->get_param( 'keywords' ) );
162
163 // Generation directives assembled server-side from the simplified modal.
164 $tone = sanitize_text_field( (string) $request->get_param( 'tone' ) );
165 $doc_size = sanitize_key( (string) $request->get_param( 'doc_size' ) );
166 $generate_title = (bool) $request->get_param( 'generate_title' );
167
168 // Selected instruction sets → extra system messages layered on the base
169 // (Default) system prompt. Unknown/empty ids are dropped by the resolver.
170 $instruction_ids = (array) $request->get_param( 'instruction_ids' );
171 $extra_system = $write_ai->get_instruction_messages( $instruction_ids );
172
173 switch ( $action ) {
174 case 'generate-outline':
175 // Tone steers an outline; size/title only matter for the full doc.
176 $outline_prompt = $this->wrap_topic( $prompt )
177 . $this->build_directives( $tone, $doc_size, false, false, false );
178 return $this->handle_outline( $write_ai, $post_id, $outline_prompt, $extra_system );
179
180 case 'generate-doc':
181 $doc_prompt = $this->wrap_topic( $prompt )
182 . $this->build_directives( $tone, $doc_size, $generate_title );
183 return $this->handle_doc( $write_ai, $post_id, $doc_prompt, $keywords, $action, $doc_size, $extra_system );
184
185 case 'expand-outline':
186 $outline = $this->sanitize_outline( (array) $request->get_param( 'outline' ) );
187 if ( empty( $outline ) ) {
188 return $this->error( 'ai_empty_outline', __( 'No outline provided to expand.', 'betterdocs' ), 400 );
189 }
190 $expand_prompt = $this->wrap_topic( $prompt ) . "\n\n"
191 . __( 'Write the full documentation following EXACTLY this approved outline. Keep the heading order and levels:', 'betterdocs' )
192 . "\n" . $this->render_outline( $outline )
193 . $this->build_directives( $tone, $doc_size, $generate_title );
194 return $this->handle_doc( $write_ai, $post_id, $expand_prompt, $keywords, $action, $doc_size, $extra_system );
195
196 case 'from-source':
197 // Prompt-bound source text: preserve tags (see the prompt note above).
198 $source = $this->clip( wp_check_invalid_utf8( (string) $request->get_param( 'source' ) ), self::MAX_SOURCE_LENGTH );
199 if ( '' === trim( $source ) ) {
200 return $this->error( 'ai_empty_source', __( 'Please paste some source content.', 'betterdocs' ), 400 );
201 }
202 $src_type = sanitize_text_field( (string) $request->get_param( 'source_type' ) );
203 $src_labels = array(
204 'transcript' => __( 'support transcript', 'betterdocs' ),
205 'forum' => __( 'forum thread', 'betterdocs' ),
206 'notes' => __( 'raw notes', 'betterdocs' ),
207 );
208 $src_label = isset( $src_labels[ $src_type ] ) ? $src_labels[ $src_type ] : __( 'source material', 'betterdocs' );
209
210 // Light per-type framing: a one-line system hint steering how to treat
211 // this kind of material. Auto-detect (empty source_type) sends no hint.
212 $src_frames = array(
213 'transcript' => __( 'The source below is a customer-support conversation. Focus on the user\'s problem and its resolution; ignore greetings and small talk.', 'betterdocs' ),
214 'forum' => __( 'The source below is a forum discussion among multiple people. Treat the accepted or most-supported answer as authoritative and skip off-topic replies.', 'betterdocs' ),
215 'notes' => __( 'The source below is rough notes. Expand them into clear, complete prose.', 'betterdocs' ),
216 );
217 if ( isset( $src_frames[ $src_type ] ) ) {
218 array_unshift( $extra_system, array( 'role' => 'system', 'content' => $src_frames[ $src_type ] ) );
219 }
220
221 $source_prompt = trim( $prompt . "\n\n"
222 . sprintf(
223 /* translators: %s: source content type, e.g. "support transcript". */
224 __( 'Turn the following %s into structured documentation. Use only the information it contains; do not invent details:', 'betterdocs' ),
225 $src_label
226 )
227 . "\n---\n" . $source . "\n---" )
228 . $this->build_directives( $tone, $doc_size, $generate_title );
229 return $this->handle_doc( $write_ai, $post_id, $source_prompt, $keywords, $action, $doc_size, $extra_system );
230
231 case 'from-attachment':
232 // Upload a file; extract its text server-side and treat it exactly
233 // like from-source (same "use only what it contains" contract and
234 // the same handle_doc → wp_kses_post output path). The file itself
235 // is never stored or rendered — only its extracted text is used as
236 // grounded prompt context.
237 $extracted = $this->read_uploaded_attachment( $request );
238 if ( is_wp_error( $extracted ) ) {
239 return $this->error( $extracted->get_error_code() ?: 'ai_attachment_failed', $extracted->get_error_message(), 400 );
240 }
241
242 // Image attachment → send the picture to a vision-capable model
243 // instead of extracting text (there is none). Same handle_doc
244 // output path (wp_kses_post), just a multimodal request.
245 if ( isset( $extracted['kind'] ) && 'image' === $extracted['kind'] ) {
246 $image_prompt = trim( $prompt . "\n\n"
247 . sprintf(
248 /* translators: %s: the uploaded image file name. */
249 __( 'Read the attached image "%s" and turn what it shows — its text, tables, diagrams, UI or screenshots — into structured documentation. Describe only what is actually visible in the image; do not invent details:', 'betterdocs' ),
250 $extracted['name']
251 ) )
252 . $this->build_directives( $tone, $doc_size, $generate_title );
253
254 return $this->handle_doc( $write_ai, $post_id, $image_prompt, $keywords, $action, $doc_size, $extra_system, $extracted );
255 }
256
257 // Extracted file text is prompt-bound source (not rendered as HTML),
258 // so preserve angle brackets like from-source/from-git do.
259 $file_text = $this->clip( wp_check_invalid_utf8( (string) $extracted['text'], true ), self::MAX_SOURCE_LENGTH );
260 if ( '' === trim( $file_text ) ) {
261 return $this->error( 'ai_empty_attachment', __( 'No readable text was found in that file.', 'betterdocs' ), 400 );
262 }
263
264 $file_prompt = trim( $prompt . "\n\n"
265 . sprintf(
266 /* translators: %s: the uploaded file name. */
267 __( 'Turn the content of the uploaded file "%s" into structured documentation. Use only the information it contains; do not invent details:', 'betterdocs' ),
268 $extracted['name']
269 )
270 . "\n---\n" . $file_text . "\n---" )
271 . $this->build_directives( $tone, $doc_size, $generate_title );
272 return $this->handle_doc( $write_ai, $post_id, $file_prompt, $keywords, $action, $doc_size, $extra_system );
273
274 case 'git-repos':
275 case 'git-items':
276 case 'git-contents':
277 // "Browse repository" data for the From Git tab. Read-only listing
278 // that delegates to Pro (token + Git API live there). The picker
279 // builds a github.com URL client-side and generation still runs via
280 // the 'from-git' fetch above.
281 if ( ! betterdocs()->is_pro_active() ) {
282 return $this->error( 'pro_required', __( 'Generating from Git is a BetterDocs Pro feature.', 'betterdocs' ), 403 );
283 }
284
285 if ( 'git-repos' === $action ) {
286 $list = apply_filters( 'betterdocs_write_with_ai_git_repos', null );
287 $payload_key = 'repos';
288 } elseif ( 'git-items' === $action ) {
289 $repo = sanitize_text_field( (string) $request->get_param( 'repo' ) );
290 $kind = sanitize_key( (string) $request->get_param( 'kind' ) );
291 if ( '' === $repo ) {
292 return $this->error( 'git_bad_repo', __( 'Please choose a repository.', 'betterdocs' ), 400 );
293 }
294 if ( ! in_array( $kind, array( 'pull', 'issue' ), true ) ) {
295 $kind = 'pull';
296 }
297 $list = apply_filters( 'betterdocs_write_with_ai_git_items', null, $repo, $kind );
298 $payload_key = 'items';
299 } else { // git-contents
300 $repo = sanitize_text_field( (string) $request->get_param( 'repo' ) );
301 // Path segments come from GitHub's contents API verbatim; keep
302 // slashes/spaces (sanitize_text_field trims tags, not slashes).
303 $path = sanitize_text_field( (string) $request->get_param( 'path' ) );
304 $ref = sanitize_text_field( (string) $request->get_param( 'ref' ) );
305 if ( '' === $repo ) {
306 return $this->error( 'git_bad_repo', __( 'Please choose a repository.', 'betterdocs' ), 400 );
307 }
308 $list = apply_filters( 'betterdocs_write_with_ai_git_contents', null, $repo, $path, $ref );
309 $payload_key = null; // return the { ref, path, items } structure as-is
310 }
311
312 if ( is_wp_error( $list ) ) {
313 return $this->error( $list->get_error_code() ?: 'git_list_failed', $list->get_error_message(), 400 );
314 }
315 if ( null === $list ) {
316 return $this->error( 'git_unavailable', __( 'Could not reach Git. Confirm Git Sync is connected.', 'betterdocs' ), 400 );
317 }
318 return $this->success( null === $payload_key ? (array) $list : array( $payload_key => $list ) );
319
320 case 'from-git':
321 // From Git is a Pro feature — the fetch runs in betterdocs-pro. The
322 // modal already blocks this without Pro, but keep the endpoint honest.
323 if ( ! betterdocs()->is_pro_active() ) {
324 return $this->error( 'pro_required', __( 'Generating from Git is a BetterDocs Pro feature.', 'betterdocs' ), 403 );
325 }
326
327 $git_url = esc_url_raw( trim( (string) $request->get_param( 'git_url' ) ) );
328 if ( '' === $git_url ) {
329 return $this->error( 'ai_empty_git', __( 'Please paste a Git URL (a pull request or a repository file).', 'betterdocs' ), 400 );
330 }
331
332 // Delegate the actual fetch to Pro (token + API client live there).
333 $fetched = apply_filters( 'betterdocs_write_with_ai_git_fetch', null, $git_url, array( 'post_id' => $post_id ) );
334
335 if ( is_wp_error( $fetched ) ) {
336 return $this->error( $fetched->get_error_code() ?: 'git_fetch_failed', $fetched->get_error_message(), 400 );
337 }
338 if ( empty( $fetched ) || empty( $fetched['content'] ) ) {
339 return $this->error( 'git_unavailable', __( 'Could not read anything from that Git URL. Check the link, or confirm Git Sync is connected.', 'betterdocs' ), 400 );
340 }
341
342 // Fetched Git content is code/diffs; preserve tags (see the prompt note above).
343 $git_content = $this->clip( wp_check_invalid_utf8( (string) $fetched['content'] ), self::MAX_SOURCE_LENGTH );
344 if ( '' === trim( $git_content ) ) {
345 return $this->error( 'git_unavailable', __( 'The fetched Git content was empty.', 'betterdocs' ), 400 );
346 }
347 $git_label = ! empty( $fetched['source_label'] ) ? sanitize_text_field( (string) $fetched['source_label'] ) : __( 'source material', 'betterdocs' );
348
349 // Optional per-intent framing, mirroring from-source's per-type hints.
350 $git_action = sanitize_key( (string) $request->get_param( 'git_action' ) );
351 $git_frames = array(
352 'document_feature' => __( 'The source below was fetched from a Git pull request or code change. Explain, in end-user documentation terms, what the feature does and how to use it — not the implementation details or code.', 'betterdocs' ),
353 'adapt_doc' => __( 'The source below is an existing documentation file from a Git repository. Rewrite it as a fresh doc for this site, keeping the meaning but improving clarity and structure.', 'betterdocs' ),
354 'howto' => __( 'Turn the source below into a concise, step-by-step how-to guide.', 'betterdocs' ),
355 );
356 if ( isset( $git_frames[ $git_action ] ) ) {
357 array_unshift( $extra_system, array( 'role' => 'system', 'content' => $git_frames[ $git_action ] ) );
358 }
359
360 $git_prompt = trim( $prompt . "\n\n"
361 . sprintf(
362 /* translators: %s: the kind of Git source, e.g. "pull request" or "documentation file". */
363 __( 'Turn the following %s into structured documentation. Use only the information it contains; do not invent details:', 'betterdocs' ),
364 $git_label
365 )
366 . "\n---\n" . $git_content . "\n---" )
367 . $this->build_directives( $tone, $doc_size, $generate_title );
368 return $this->handle_doc( $write_ai, $post_id, $git_prompt, $keywords, $action, $doc_size, $extra_system );
369
370 default:
371 return $this->error( 'ai_bad_action', __( 'Unknown AI action.', 'betterdocs' ), 400 );
372 }
373 }
374
375 /**
376 * Full-doc generation (generate-doc, expand-outline, from-source all land here).
377 */
378 protected function handle_doc( $write_ai, $post_id, $prompt, $keywords, $action, $doc_size = 'any', $extra_system = array(), $image = null ) {
379 if ( '' === trim( $prompt ) ) {
380 return $this->error( 'ai_empty_prompt', __( 'Please provide a prompt for the AI.', 'betterdocs' ), 400 );
381 }
382
383 // A "long" doc can outrun the default 2500-token cap; give it headroom.
384 $max_tokens = 'long' === $doc_size ? 4000 : null;
385
386 if ( null !== $image ) {
387 // Image attachment: send the picture to a vision model. Returns a
388 // WP_Error when the configured model can't read images (guard) — surface
389 // that as a 400 so the user knows to switch models, not a 502.
390 $content = $write_ai->generate_vision_response( $prompt, $image, $max_tokens, $extra_system );
391 if ( is_wp_error( $content ) ) {
392 $code = $content->get_error_code() ?: 'ai_vision_failed';
393 return $this->error( $code, $content->get_error_message(), 'ai_no_vision' === $code ? 400 : 502 );
394 }
395 } else {
396 $content = $write_ai->generate_openai_response( $prompt, $keywords, $max_tokens, $extra_system );
397 }
398
399 if ( ! is_string( $content ) || '' === trim( $content ) ) {
400 return $this->error( 'empty', __( 'The AI returned no content. Try again or rephrase your prompt.', 'betterdocs' ), 502 );
401 }
402 if ( 0 === strpos( $content, 'Error:' ) ) {
403 return $this->error( 'ai_upstream', $content, 502 );
404 }
405
406 // Sanitize the model-generated HTML before it leaves the server: the editor
407 // renders it via dangerouslySetInnerHTML in the preview and inserts it as
408 // blocks, so strip <script>, event-handler attributes, <iframe> and
409 // javascript: URLs while keeping valid documentation markup. The system
410 // prompt asks the model to avoid these, but that is a soft constraint — this
411 // is the enforcement (a prompt-injected source/Git payload can't inject XSS).
412 $content = wp_kses_post( $content );
413
414 AIUsage::record( 'write_with_ai', $post_id, $action );
415
416 return $this->success( array( 'content' => $content, 'action' => $action ) );
417 }
418
419 /**
420 * Outline-only generation.
421 */
422 protected function handle_outline( $write_ai, $post_id, $prompt, $extra_system = array() ) {
423 if ( '' === trim( $prompt ) ) {
424 return $this->error( 'ai_empty_prompt', __( 'Please provide a prompt for the AI.', 'betterdocs' ), 400 );
425 }
426
427 $result = $write_ai->generate_outline_response( $prompt, $extra_system );
428
429 if ( empty( $result['success'] ) ) {
430 $message = isset( $result['error'] ) ? (string) $result['error'] : __( 'Unknown AI error.', 'betterdocs' );
431 return $this->error( 'ai_upstream', $message, 502 );
432 }
433
434 AIUsage::record( 'write_with_ai', $post_id, 'generate-outline' );
435
436 return $this->success( array( 'outline' => $result['outline'], 'action' => 'generate-outline' ) );
437 }
438
439 /**
440 * Normalize an outline payload into a clean list of { level, text } items.
441 *
442 * @param array $raw
443 * @return array<int,array{level:string,text:string}>
444 */
445 protected function sanitize_outline( $raw ) {
446 $outline = array();
447 foreach ( $raw as $item ) {
448 if ( ! is_array( $item ) || empty( $item['text'] ) ) {
449 continue;
450 }
451 $level = isset( $item['level'] ) && 'h3' === strtolower( (string) $item['level'] ) ? 'h3' : 'h2';
452 $text = sanitize_text_field( (string) $item['text'] );
453 if ( '' === $text ) {
454 continue;
455 }
456 $outline[] = array( 'level' => $level, 'text' => $text );
457 }
458 return $outline;
459 }
460
461 /**
462 * Render an outline array into an indented plain-text list for the prompt.
463 */
464 protected function render_outline( $outline ) {
465 $lines = array();
466 foreach ( $outline as $sec ) {
467 $prefix = 'h3' === $sec['level'] ? ' - ' : '- ';
468 $lines[] = $prefix . $sec['text'];
469 }
470 return implode( "\n", $lines );
471 }
472
473 /**
474 * Validate the uploaded "From Attachment" file and return its extracted text.
475 *
476 * Security: enforces is_uploaded_file (a real HTTP upload, not an arbitrary
477 * server path), a byte cap, and a strict extension + MIME allow-list via
478 * wp_check_filetype(). The file is read for text only — never moved into the
479 * uploads dir, stored, or rendered — so there is no persisted attack surface.
480 *
481 * @param WP_REST_Request $request
482 * @return array{name:string,text:string}|\WP_Error
483 */
484 protected function read_uploaded_attachment( WP_REST_Request $request ) {
485 $files = $request->get_file_params();
486 if ( empty( $files['file'] ) || ! is_array( $files['file'] ) ) {
487 return new \WP_Error( 'ai_no_file', __( 'No file was received. Choose a file to write from.', 'betterdocs' ) );
488 }
489
490 $file = $files['file'];
491
492 if ( ! empty( $file['error'] ) || empty( $file['tmp_name'] ) || ! is_uploaded_file( $file['tmp_name'] ) ) {
493 return new \WP_Error( 'ai_upload_failed', __( 'The upload did not complete — please try again.', 'betterdocs' ) );
494 }
495
496 if ( (int) $file['size'] > self::MAX_UPLOAD_BYTES ) {
497 return new \WP_Error(
498 'ai_file_too_large',
499 sprintf(
500 /* translators: %s: maximum allowed size, e.g. "5 MB". */
501 __( 'The file exceeds the %s limit.', 'betterdocs' ),
502 size_format( self::MAX_UPLOAD_BYTES )
503 )
504 );
505 }
506
507 // Strict extension + MIME allow-list. wp_check_filetype() validates the
508 // name against exactly these types; anything else yields an empty ext.
509 $allowed = array(
510 'txt' => 'text/plain',
511 'md|markdown' => 'text/markdown',
512 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
513 'pdf' => 'application/pdf',
514 'png' => 'image/png',
515 'jpg|jpeg' => 'image/jpeg',
516 'webp' => 'image/webp',
517 );
518 $check = wp_check_filetype( (string) $file['name'], $allowed );
519 $ext = strtolower( (string) $check['ext'] );
520
521 $image_exts = array( 'png', 'jpg', 'jpeg', 'webp' );
522 $text_exts = array( 'txt', 'md', 'markdown', 'docx', 'pdf' );
523
524 if ( ! in_array( $ext, array_merge( $text_exts, $image_exts ), true ) ) {
525 return new \WP_Error( 'ai_bad_filetype', __( 'Unsupported file type. Upload a .pdf, .docx, .txt, .md, or an image (.png, .jpg, .webp).', 'betterdocs' ) );
526 }
527
528 // Image → send the picture itself to a vision model (there is no text to
529 // extract). Verify it is a real image by its bytes, not just its name,
530 // then hand back a base64 data URI for the multimodal request.
531 if ( in_array( $ext, $image_exts, true ) ) {
532 $raw = file_get_contents( (string) $file['tmp_name'] ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- local tmp upload.
533 if ( false === $raw || '' === $raw ) {
534 return new \WP_Error( 'ai_read_failed', __( 'Could not read the image file.', 'betterdocs' ) );
535 }
536
537 $info = @getimagesize( (string) $file['tmp_name'] );
538 $mime = ( is_array( $info ) && ! empty( $info['mime'] ) ) ? (string) $info['mime'] : '';
539
540 if ( ! in_array( $mime, array( 'image/png', 'image/jpeg', 'image/webp' ), true ) ) {
541 return new \WP_Error( 'ai_bad_image', __( 'That file is not a valid PNG, JPG or WEBP image.', 'betterdocs' ) );
542 }
543
544 return array(
545 'name' => sanitize_file_name( (string) $file['name'] ),
546 'kind' => 'image',
547 'mime' => $mime,
548 'data_uri' => 'data:' . $mime . ';base64,' . base64_encode( $raw ),
549 );
550 }
551
552 $text = $this->extract_attachment_text( (string) $file['tmp_name'], $ext );
553 if ( is_wp_error( $text ) ) {
554 return $text;
555 }
556
557 return array(
558 'name' => sanitize_file_name( (string) $file['name'] ),
559 'kind' => 'text',
560 'text' => $text,
561 );
562 }
563
564 /**
565 * Extract plain text from a supported uploaded file. TXT/MD are read as-is;
566 * DOCX is unzipped natively (ZipArchive) and its document body flattened;
567 * PDF text is pulled natively from FlateDecode content streams — all without
568 * a third-party parser dependency. Images/scanned PDFs (no embedded text) are
569 * not handled here (that would need OCR / a multimodal model).
570 *
571 * @param string $path Local tmp upload path (already is_uploaded_file-verified).
572 * @param string $ext Allow-listed extension.
573 * @return string|\WP_Error
574 */
575 protected function extract_attachment_text( $path, $ext ) {
576 if ( in_array( $ext, array( 'txt', 'md', 'markdown' ), true ) ) {
577 $raw = file_get_contents( $path ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- local tmp upload.
578 return false === $raw ? new \WP_Error( 'ai_read_failed', __( 'Could not read the file.', 'betterdocs' ) ) : $raw;
579 }
580
581 if ( 'docx' === $ext ) {
582 if ( ! class_exists( '\ZipArchive' ) ) {
583 return new \WP_Error( 'ai_no_zip', __( 'Reading .docx files needs the PHP Zip extension, which is not available on this server. Upload a .txt or .md instead.', 'betterdocs' ) );
584 }
585 $zip = new \ZipArchive();
586 if ( true !== $zip->open( $path ) ) {
587 return new \WP_Error( 'ai_bad_docx', __( 'Could not open that .docx file — it may be corrupt.', 'betterdocs' ) );
588 }
589 $xml = $zip->getFromName( 'word/document.xml' );
590 $zip->close();
591
592 if ( false === $xml || '' === $xml ) {
593 return new \WP_Error( 'ai_bad_docx', __( 'That .docx file has no readable document body.', 'betterdocs' ) );
594 }
595
596 // Turn Word paragraph/break/tab elements into whitespace, then strip
597 // every remaining tag so only the run text (<w:t>) survives, and decode
598 // XML entities. Keeps paragraph structure the model can read.
599 $xml = preg_replace( '#</w:p>#', "\n\n", $xml );
600 $xml = preg_replace( '#<w:br\b[^>]*/?>#', "\n", $xml );
601 $xml = preg_replace( '#<w:tab\b[^>]*/?>#', "\t", $xml );
602 $text = wp_strip_all_tags( (string) $xml );
603 $text = html_entity_decode( $text, ENT_QUOTES | ENT_XML1, 'UTF-8' );
604
605 return trim( preg_replace( "/\n{3,}/", "\n\n", $text ) );
606 }
607
608 if ( 'pdf' === $ext ) {
609 return $this->extract_pdf_text( $path );
610 }
611
612 return new \WP_Error( 'ai_bad_filetype', __( 'Unsupported file type.', 'betterdocs' ) );
613 }
614
615 /**
616 * Extract text from a PDF natively — no library. PDFs keep their page text in
617 * "content streams" (usually zlib/FlateDecode-compressed); we inflate each one
618 * and pull the operands of the text-showing operators (Tj / TJ / ' / "). This
619 * covers the common case (real, text-based documents). It intentionally does
620 * NOT handle:
621 * - encrypted PDFs (no key) — reported so the user knows why,
622 * - scanned/image-only PDFs (there is no embedded text to read) — reported,
623 * - exotic font encodings (CID/Type0 with custom CMaps) — those decode to
624 * garbled text, so we drop a stream whose result looks non-textual.
625 * The extracted text is prompt-bound source only; it is never rendered, and
626 * the model output still passes wp_kses_post downstream.
627 *
628 * @param string $path Local tmp upload path.
629 * @return string|\WP_Error
630 */
631 protected function extract_pdf_text( $path ) {
632 $data = file_get_contents( $path ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- local tmp upload.
633 if ( false === $data || 0 !== strncmp( $data, '%PDF', 4 ) ) {
634 return new \WP_Error( 'ai_bad_pdf', __( 'That does not look like a valid PDF file.', 'betterdocs' ) );
635 }
636
637 // An encrypted PDF's streams won't inflate to readable text without the
638 // key. Detect the Encrypt entry up front and say so, rather than return
639 // empty. (An /Encrypt inside a literal string is a rare false positive we
640 // accept — worst case the user gets the "no text" message below instead.)
641 if ( preg_match( '/\/Encrypt\b/', $data ) ) {
642 return new \WP_Error( 'ai_pdf_encrypted', __( 'This PDF is password-protected or encrypted, so its text can\'t be read. Remove the protection, or paste the text instead.', 'betterdocs' ) );
643 }
644
645 $out = '';
646 $cap = self::MAX_SOURCE_LENGTH + 4000; // stop early; the source is clipped later anyway.
647
648 if ( preg_match_all( '/stream\r?\n(.*?)\r?\nendstream/s', $data, $streams ) ) {
649 foreach ( $streams[1] as $chunk ) {
650 // Try zlib (FlateDecode) first, then raw-deflate, then treat as
651 // already-plain. @-silenced: a binary (image/font) stream simply
652 // fails to inflate and is skipped below.
653 $decoded = @gzuncompress( $chunk );
654 if ( false === $decoded ) {
655 $decoded = @gzinflate( $chunk );
656 }
657 $content = ( is_string( $decoded ) && '' !== $decoded ) ? $decoded : $chunk;
658
659 // Only content streams carry text-showing operators; skip the rest
660 // (images, fonts) so we don't scrape binary noise.
661 if ( false === strpos( $content, 'Tj' ) && false === strpos( $content, 'TJ' ) ) {
662 continue;
663 }
664
665 $piece = $this->pdf_stream_text( $content );
666 // Guard against garbled CID/font-encoded streams: if the decoded
667 // "text" is mostly non-printable, drop it rather than inject noise.
668 if ( '' !== $piece && $this->mostly_printable( $piece ) ) {
669 $out .= $piece . "\n";
670 if ( strlen( $out ) > $cap ) {
671 break;
672 }
673 }
674 }
675 }
676
677 $out = preg_replace( "/[ \t]+/", ' ', $out );
678 $out = trim( preg_replace( "/\n{3,}/", "\n\n", $out ) );
679
680 // Subsetted LaTeX/CID fonts emit control bytes (ligatures) and non-UTF-8
681 // sequences among the readable text. Strip them and coerce to valid UTF-8 —
682 // otherwise the caller's wp_check_invalid_utf8() discards the ENTIRE string
683 // on the first bad byte and an 8-page paper looks empty ("no readable text").
684 $out = $this->to_clean_utf8( $out );
685
686 if ( '' === $out ) {
687 return new \WP_Error(
688 'ai_pdf_no_text',
689 __( 'No selectable text was found in that PDF — it may be a scanned image. Try a text-based PDF, or paste the content into the prompt.', 'betterdocs' )
690 );
691 }
692
693 return $out;
694 }
695
696 /**
697 * Pull the visible text out of one decoded PDF content stream. Positioning
698 * operators (Td/TD/T*) become newlines; the literal `( … )` and hex `< … >`
699 * operands of Tj/TJ/'/'' become the text. Kerning numbers inside TJ arrays are
700 * ignored (their effect on spacing is cosmetic for our purposes).
701 */
702 protected function pdf_stream_text( $content ) {
703 // Text-positioning operators (new line / new paragraph) become newlines so
704 // words on different lines don't run together.
705 $content = preg_replace( '/\b(?:T\*|Td|TD)\b/', " \n ", $content );
706
707 // Walk TJ arrays and Tj/'/'" strings in document order. Inside a TJ array
708 // pdfTeX (LaTeX) renders an inter-word space as a large negative kerning
709 // number, not a literal space in the string — so we synthesise a space when
710 // the kerning passes a threshold, otherwise every word runs together
711 // ("FormallyVerifiedand…"). Small kerning (letter pairs) is ignored.
712 if ( ! preg_match_all(
713 '/\[((?:\\\\.|[^\]\\\\])*)\]\s*TJ|(\((?:\\\\.|[^\\\\()])*\)|<[0-9A-Fa-f\s]+>)\s*(?:Tj|\'|")|(\n)/s',
714 $content,
715 $matches,
716 PREG_SET_ORDER
717 ) ) {
718 return '';
719 }
720
721 $text = '';
722 foreach ( $matches as $tok ) {
723 if ( isset( $tok[3] ) && "\n" === $tok[3] ) {
724 $text .= "\n";
725 continue;
726 }
727 if ( isset( $tok[1] ) && '' !== $tok[1] ) {
728 // TJ array: alternating string operands and kerning numbers.
729 preg_match_all( '/\((?:\\\\.|[^\\\\()])*\)|<[0-9A-Fa-f\s]+>|-?\d+(?:\.\d+)?/s', $tok[1], $parts );
730 foreach ( $parts[0] as $part ) {
731 if ( '(' === $part[0] || '<' === $part[0] ) {
732 $text .= $this->pdf_token_text( $part );
733 } elseif ( (float) $part < -100 ) {
734 $text .= ' ';
735 }
736 }
737 $text .= ' ';
738 } elseif ( isset( $tok[2] ) && '' !== $tok[2] ) {
739 $text .= $this->pdf_token_text( $tok[2] ) . ' ';
740 }
741 }
742
743 return preg_replace( '/[^\S\n]+/', ' ', $text );
744 }
745
746 /**
747 * Decode one PDF string operand — a literal `( … )` (with escapes) or a hex
748 * `< … >` string — into its raw bytes.
749 */
750 protected function pdf_token_text( $token ) {
751 if ( '(' === $token[0] ) {
752 return $this->pdf_unescape( substr( $token, 1, -1 ) );
753 }
754 $hex = preg_replace( '/[^0-9A-Fa-f]/', '', $token );
755 return ( '' === $hex ) ? '' : (string) @hex2bin( strlen( $hex ) % 2 ? substr( $hex, 0, -1 ) : $hex );
756 }
757
758 /**
759 * Resolve PDF string escapes: \( \) \\ \n \r \t \b \f and \ddd octal codes.
760 */
761 protected function pdf_unescape( $string ) {
762 return preg_replace_callback(
763 '/\\\\(?:([nrtbf()\\\\])|([0-7]{1,3}))/',
764 function ( $mm ) {
765 if ( isset( $mm[1] ) && '' !== $mm[1] ) {
766 $map = array( 'n' => "\n", 'r' => "\r", 't' => "\t", 'b' => "\x08", 'f' => "\x0C", '(' => '(', ')' => ')', '\\' => '\\' );
767 return isset( $map[ $mm[1] ] ) ? $map[ $mm[1] ] : $mm[1];
768 }
769 return chr( octdec( $mm[2] ) & 0xFF );
770 },
771 $string
772 );
773 }
774
775 /**
776 * Is this decoded string mostly readable text? Used to drop font/CID streams
777 * that decode to binary-looking garbage. Counts printable + common whitespace.
778 */
779 protected function mostly_printable( $string ) {
780 $len = strlen( $string );
781 if ( 0 === $len ) {
782 return false;
783 }
784 $printable = preg_match_all( '/[\P{Cc}\t\n\r]/u', $string );
785 // Fallback for non-UTF-8 payloads where \p{} may not match cleanly.
786 if ( false === $printable ) {
787 $printable = strlen( preg_replace( '/[^\x09\x0A\x0D\x20-\x7E]/', '', $string ) );
788 }
789 return ( $printable / $len ) >= 0.7;
790 }
791
792 protected function clip( $value, $max ) {
793 return strlen( $value ) > $max ? substr( $value, 0, $max ) : $value;
794 }
795
796 /**
797 * Coerce extracted PDF bytes to clean, valid UTF-8: drop C0/C1 control bytes
798 * (except tab/newline) and any byte sequence that isn't valid UTF-8. This keeps
799 * the readable text intact for the downstream wp_check_invalid_utf8(), which
800 * would otherwise discard the whole string on a single invalid byte.
801 */
802 protected function to_clean_utf8( $string ) {
803 $string = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', (string) $string );
804 if ( '' !== $string && ! preg_match( '//u', $string ) ) {
805 $converted = @iconv( 'UTF-8', 'UTF-8//IGNORE', $string );
806 $string = ( false !== $converted ) ? $converted : preg_replace( '/[^\x09\x0A\x20-\x7E]/', '', $string );
807 }
808 return (string) $string;
809 }
810
811 /**
812 * Frame the user's free-form request as a documentation instruction. Returns
813 * an empty string for an empty request (callers compose their own prompt).
814 */
815 protected function wrap_topic( $prompt ) {
816 $prompt = trim( $prompt );
817 if ( '' === $prompt ) {
818 return '';
819 }
820 return __( 'Write documentation for the following request:', 'betterdocs' ) . "\n\n" . $prompt;
821 }
822
823 /**
824 * Build the tone / size / title directive block appended to the prompt. Tone
825 * applies to every action; size and the title instruction are doc-only.
826 *
827 * @param string $tone Selected tone slug ('' = default, no directive).
828 * @param string $doc_size Selected size slug ('any' = no directive).
829 * @param bool $generate_title Whether the AI should also produce an <h1> title.
830 * @param bool $include_size Include the size directive (false for outlines).
831 * @param bool $include_title Include the title directive (false for outlines).
832 * @return string Leading "\n\n" + directives, or '' when none apply.
833 */
834 protected function build_directives( $tone, $doc_size, $generate_title, $include_size = true, $include_title = true ) {
835 $lines = array();
836
837 $tone_map = array(
838 'friendly' => __( 'Write in a warm, friendly, approachable tone.', 'betterdocs' ),
839 'professional' => __( 'Write in a polished, professional tone.', 'betterdocs' ),
840 'technical' => __( 'Write in a precise, technical tone suited to a technical audience.', 'betterdocs' ),
841 'formal' => __( 'Write in a formal tone.', 'betterdocs' ),
842 'casual' => __( 'Write in a casual, conversational tone.', 'betterdocs' ),
843 );
844 if ( isset( $tone_map[ $tone ] ) ) {
845 $lines[] = $tone_map[ $tone ];
846 }
847
848 if ( $include_size ) {
849 $size_map = array(
850 'short' => __( 'Keep the documentation concise — roughly 300–500 words, covering only the essential points.', 'betterdocs' ),
851 'medium' => __( 'Aim for a moderate length — roughly 600–1000 words.', 'betterdocs' ),
852 'long' => __( 'Be comprehensive and in-depth — roughly 1200 words or more, with thorough coverage and examples.', 'betterdocs' ),
853 );
854 if ( isset( $size_map[ $doc_size ] ) ) {
855 $lines[] = $size_map[ $doc_size ];
856 }
857 }
858
859 if ( $include_title && $generate_title ) {
860 $lines[] = __( 'Begin the output with a single <h1> element containing a concise, descriptive title for this documentation, then continue with the body content.', 'betterdocs' );
861 }
862
863 return empty( $lines ) ? '' : "\n\n" . implode( "\n", $lines );
864 }
865 }
866