PluginProbe
WPBot – AI ChatBot for Live Support, Lead Generation, WordPress Automation, AI Services / 8.8.2
WPBot – AI ChatBot for Live Support, Lead Generation, WordPress Automation, AI Services v8.8.2
8.8.2 8.8.1 8.8.0 8.7.9 8.7.8 8.7.7 8.7.6 8.7.5 8.7.4 8.7.3 8.7.2 8.7.1 8.7.0 8.6.9 8.6.8 8.6.7 8.6.6 8.6.5 8.6.4 8.6.2 8.6.1 8.6.0 8.5.9 8.5.8 8.5.7 All 538 releases
← All changes | addons/automator/includes/actions/google-sheets-actions.php +652 -652 8.7.9 → 8.8.2 View file →
@@ -1,652 +1,652 @@
1 -<?php
2 -/**
3 - * Google Sheets Actions
4 - *
5 - * @package WPbot_Automator
6 - */
7 -
8 -namespace WPbot_Automator\Actions;
9 -
10 -if ( ! defined( 'ABSPATH' ) ) {
11 - exit;
12 -}
13 -
14 -/**
15 - * Class Google_Sheets_Actions
16 - *
17 - * Integrates with the Google Sheets API v4 using a Service Account (JWT auth).
18 - * No OAuth redirect flow required — users paste their Service Account JSON key directly.
19 - */
20 -class Google_Sheets_Actions extends Action {
21 -
22 - /**
23 - * Constructor
24 - */
25 - public function __construct() {
26 - $this->id = 'google_sheets';
27 - $this->group = __( 'Google Sheets', 'wpbot-automator' );
28 - }
29 -
30 - /**
31 - * Execute the action
32 - *
33 - * @param array $action_data Configuration for this action from the workflow.
34 - * @param array $trigger_data Data passed from the trigger.
35 - * @return bool|array
36 - */
37 - public function execute( $action_data, $trigger_data ) {
38 - $action_id = isset( $action_data['actionId'] ) ? $action_data['actionId'] : '';
39 -
40 - if ( empty( $action_id ) ) {
41 - return false;
42 - }
43 -
44 - switch ( $action_id ) {
45 - case 'gs_add_row':
46 - case 'add_row':
47 - return $this->add_row( $action_data, $trigger_data );
48 - case 'gs_update_row':
49 - case 'update_row':
50 - return $this->update_row( $action_data, $trigger_data );
51 - case 'gs_clear_range':
52 - case 'clear_range':
53 - return $this->clear_range( $action_data, $trigger_data );
54 - case 'gs_get_values':
55 - case 'get_values':
56 - return $this->get_values( $action_data, $trigger_data );
57 - default:
58 - return false;
59 - }
60 - }
61 -
62 - // ─────────────────────────────────────────────────────────────────────────
63 - // Action Handlers
64 - // ─────────────────────────────────────────────────────────────────────────
65 -
66 - /**
67 - * Append a new row to a Google Sheet.
68 - */
69 - private function add_row( $action_data, $trigger_data ) {
70 -
71 - $cfg = isset( $action_data['config'] ) ? $action_data['config'] : array();
72 - $spreadsheet_id = trim( $this->parse_tokens( $cfg['spreadsheet_id'] ?? $cfg['sheet_id'] ?? '', $trigger_data ) );
73 - $sheet_name_raw = trim( $this->parse_tokens( $cfg['sheet_name'] ?? $cfg['worksheet'] ?? '', $trigger_data ) );
74 - $sheet_name = ! empty( $sheet_name_raw ) ? $sheet_name_raw : 'Sheet1';
75 - $row_data_temp = $cfg['row_data'] ?? '';
76 - $sa_json = trim( $cfg['service_account_json'] ?? '' );
77 -
78 - if ( empty( $sa_json ) ) {
79 - $sa_json = get_option( 'wpbot_automator_gs_sa_json', '' );
80 - }
81 -
82 - if ( empty( $spreadsheet_id ) || empty( $sa_json ) ) {
83 - return array(
84 - 'success' => false,
85 - 'message' => 'Spreadsheet ID and Service Account JSON are required.',
86 - );
87 - }
88 -
89 - $token = $this->get_access_token( $sa_json );
90 - if ( is_wp_error( $token ) ) {
91 - return array( 'success' => false, 'message' => $token->get_error_message() );
92 - }
93 -
94 - // ✅ Validate Sheet Exists First
95 - $sheet_check = wp_remote_get(
96 - "https://sheets.googleapis.com/v4/spreadsheets/{$spreadsheet_id}?fields=sheets.properties.title",
97 - array(
98 - 'headers' => array(
99 - 'Authorization' => 'Bearer ' . $token,
100 - ),
101 - 'timeout' => 20,
102 - )
103 - );
104 -
105 - if ( is_wp_error( $sheet_check ) ) {
106 - return array( 'success' => false, 'message' => $sheet_check->get_error_message() );
107 - }
108 -
109 - $sheet_body = json_decode( wp_remote_retrieve_body( $sheet_check ), true );
110 -
111 - if ( empty( $sheet_body['sheets'] ) ) {
112 - return array( 'success' => false, 'message' => 'Unable to retrieve sheets from spreadsheet.' );
113 - }
114 -
115 - $available_sheets = array_map(
116 - function ( $s ) {
117 - return $s['properties']['title'];
118 - },
119 - $sheet_body['sheets']
120 - );
121 -
122 - if ( ! in_array( $sheet_name, $available_sheets, true ) ) {
123 - $create_missing = $cfg['create_missing_sheet'] ?? true; // Default to true if not set
124 - if ( $create_missing ) {
125 - $created = $this->create_sheet( $spreadsheet_id, $sheet_name, $sa_json );
126 - if ( is_wp_error( $created ) ) {
127 - return array( 'success' => false, 'message' => 'Sheet "' . esc_html( $sheet_name ) . '" not found and auto-creation failed: ' . $created->get_error_message() );
128 - }
129 - } else {
130 - return array(
131 - 'success' => false,
132 - 'message' => 'Sheet "' . esc_html( $sheet_name ) . '" not found. Available sheets: ' . implode( ', ', $available_sheets ),
133 - );
134 - }
135 - }
136 -
137 - // ✅ Parse row data by splitting the template first to prevent inline commas/newlines from breaking columns
138 - if ( ! empty( $row_data_temp ) ) {
139 - $values_template = is_array( $row_data_temp ) ? $row_data_temp : preg_split( '/[\n,]+/', $row_data_temp );
140 - $values_template = array_map( 'trim', $values_template );
141 - $values = array();
142 - foreach ( $values_template as $tpl ) {
143 - if ( $tpl !== '' ) {
144 - $values[] = $this->parse_tokens( $tpl, $trigger_data );
145 - }
146 - }
147 - } else {
148 - $values = $this->extract_fields_from_trigger( $trigger_data );
149 - }
150 -
151 - if ( empty( $values ) ) {
152 - return array(
153 - 'success' => false,
154 - 'message' => 'No row data provided.',
155 - );
156 - }
157 -
158 - // ✅ Properly quote sheet name if it contains spaces or special characters
159 - $sheet_name = $this->quote_sheet_name( $sheet_name );
160 -
161 - // ✅ Use safe append range
162 - $range = $sheet_name . '!A:Z';
163 -
164 - $url = "https://sheets.googleapis.com/v4/spreadsheets/{$spreadsheet_id}/values/" . rawurlencode( $range ) . ":append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS";
165 -
166 - $response = wp_remote_post(
167 - $url,
168 - array(
169 - 'headers' => array(
170 - 'Authorization' => 'Bearer ' . $token,
171 - 'Content-Type' => 'application/json',
172 - ),
173 - 'body' => wp_json_encode(
174 - array(
175 - 'values' => array( $values ),
176 - )
177 - ),
178 - 'timeout' => 20,
179 - )
180 - );
181 -
182 - $result = $this->handle_api_response( $response, 'Add Row', $spreadsheet_id, $sa_json );
183 -
184 - if ( $result['success'] && ! empty( $cfg['service_account_json'] ) ) {
185 - update_option( 'wpbot_automator_gs_sa_json', $cfg['service_account_json'] );
186 - }
187 -
188 - return $result;
189 - }
190 -
191 - /**
192 - * Update a specific row in a Google Sheet.
193 - */
194 - private function update_row( $action_data, $trigger_data ) {
195 - $cfg = isset( $action_data['config'] ) ? $action_data['config'] : array();
196 - $spreadsheet_id = trim( $this->parse_tokens( $cfg['spreadsheet_id'] ?? $cfg['sheet_id'] ?? '', $trigger_data ) );
197 - $sheet_name_raw = trim( $this->parse_tokens( $cfg['sheet_name'] ?? $cfg['worksheet'] ?? '', $trigger_data ) );
198 - $sheet_name = ! empty( $sheet_name_raw ) ? $sheet_name_raw : 'Sheet1';
199 - $row_number = absint( $this->parse_tokens( $cfg['row_number'] ?? '', $trigger_data ) );
200 - $row_data_temp = $cfg['row_data'] ?? '';
201 - $sa_json = trim( $cfg['service_account_json'] ?? '' );
202 -
203 - if ( empty( $sa_json ) ) {
204 - $sa_json = get_option( 'wpbot_automator_gs_sa_json', '' );
205 - }
206 -
207 - if ( empty( $spreadsheet_id ) || empty( $sa_json ) || empty( $row_data_temp ) || empty( $row_number ) ) {
208 - return array(
209 - 'success' => false,
210 - 'message' => 'Google Sheets Update Row: spreadsheet_id, service_account_json, row_data, and row_number are required.',
211 - );
212 - }
213 -
214 - $token = $this->get_access_token( $sa_json );
215 - if ( is_wp_error( $token ) ) {
216 - return array( 'success' => false, 'message' => $token->get_error_message() );
217 - }
218 -
219 - $values_template = is_array( $row_data_temp ) ? $row_data_temp : preg_split( '/[\n,]+/', $row_data_temp );
220 - $values_template = array_map( 'trim', $values_template );
221 - $values = array();
222 - foreach ( $values_template as $tpl ) {
223 - if ( $tpl !== '' ) {
224 - $values[] = $this->parse_tokens( $tpl, $trigger_data );
225 - }
226 - }
227 -
228 - $range = rawurlencode( $this->quote_sheet_name( "{$sheet_name}!A{$row_number}" ) );
229 - $url = "https://sheets.googleapis.com/v4/spreadsheets/{$spreadsheet_id}/values/{$range}?valueInputOption=USER_ENTERED";
230 -
231 - $response = wp_remote_request(
232 - $url,
233 - array(
234 - 'method' => 'PUT',
235 - 'headers' => array(
236 - 'Authorization' => 'Bearer ' . $token,
237 - 'Content-Type' => 'application/json',
238 - ),
239 - 'body' => wp_json_encode(
240 - array(
241 - 'range' => $this->quote_sheet_name( "{$sheet_name}!A{$row_number}" ),
242 - 'values' => array( $values ),
243 - )
244 - ),
245 - 'timeout' => 20,
246 - )
247 - );
248 -
249 - return $this->handle_api_response( $response, 'Update Row', $spreadsheet_id, $sa_json );
250 - }
251 -
252 - /**
253 - * Clear a range in a Google Sheet.
254 - */
255 - private function clear_range( $action_data, $trigger_data ) {
256 - $cfg = isset( $action_data['config'] ) ? $action_data['config'] : array();
257 - $spreadsheet_id = trim( $this->parse_tokens( isset( $cfg['spreadsheet_id'] ) ? $cfg['spreadsheet_id'] : '', $trigger_data ) );
258 - $range = trim( $this->parse_tokens( isset( $cfg['range'] ) ? $cfg['range'] : '', $trigger_data ) );
259 - $sa_json = trim( isset( $cfg['service_account_json'] ) ? $cfg['service_account_json'] : '' );
260 -
261 - if ( empty( $sa_json ) ) {
262 - $sa_json = get_option( 'wpbot_automator_gs_sa_json', '' );
263 - }
264 -
265 - if ( empty( $spreadsheet_id ) || empty( $sa_json ) || empty( $range ) ) {
266 - return array(
267 - 'success' => false,
268 - 'message' => 'Google Sheets Clear Range: spreadsheet_id, service_account_json, and range are required.',
269 - );
270 - }
271 -
272 - $token = $this->get_access_token( $sa_json );
273 - if ( is_wp_error( $token ) ) {
274 - return array( 'success' => false, 'message' => $token->get_error_message() );
275 - }
276 -
277 - $encoded_range = rawurlencode( $this->quote_sheet_name( $range ) );
278 - $url = "https://sheets.googleapis.com/v4/spreadsheets/{$spreadsheet_id}/values/{$encoded_range}:clear";
279 -
280 - $response = wp_remote_post(
281 - $url,
282 - array(
283 - 'headers' => array(
284 - 'Authorization' => 'Bearer ' . $token,
285 - 'Content-Type' => 'application/json',
286 - ),
287 - 'body' => '{}',
288 - 'timeout' => 20,
289 - )
290 - );
291 -
292 - return $this->handle_api_response( $response, 'Clear Range', $spreadsheet_id, $sa_json );
293 - }
294 -
295 - /**
296 - * Get values from a range in a Google Sheet.
297 - */
298 - private function get_values( $action_data, $trigger_data ) {
299 - $cfg = isset( $action_data['config'] ) ? $action_data['config'] : array();
300 - $spreadsheet_id = trim( $this->parse_tokens( isset( $cfg['spreadsheet_id'] ) ? $cfg['spreadsheet_id'] : '', $trigger_data ) );
301 - $range = trim( $this->parse_tokens( isset( $cfg['range'] ) ? $cfg['range'] : '', $trigger_data ) );
302 - $sa_json = trim( isset( $cfg['service_account_json'] ) ? $cfg['service_account_json'] : '' );
303 -
304 - if ( empty( $spreadsheet_id ) || empty( $sa_json ) || empty( $range ) ) {
305 - $hint = '';
306 - if ( ! empty( $trigger_data['fields'] ) || ( isset( $trigger_data['form_id'] ) ) ) {
307 - $hint = ' Hint: If you want to SAVE form data to a sheet, please use the "Add Row (Save Form Data)" action instead.';
308 - }
309 - return array(
310 - 'success' => false,
311 - 'message' => 'Google Sheets Get Values: spreadsheet_id, service_account_json, and range are required.' . $hint,
312 - );
313 - }
314 -
315 - $token = $this->get_access_token( $sa_json );
316 - if ( is_wp_error( $token ) ) {
317 - return array( 'success' => false, 'message' => $token->get_error_message() );
318 - }
319 -
320 - $encoded_range = rawurlencode( $range );
321 - $url = "https://sheets.googleapis.com/v4/spreadsheets/{$spreadsheet_id}/values/{$encoded_range}";
322 -
323 - $response = wp_remote_get(
324 - $url,
325 - array(
326 - 'headers' => array(
327 - 'Authorization' => 'Bearer ' . $token,
328 - ),
329 - 'timeout' => 20,
330 - )
331 - );
332 -
333 - if ( is_wp_error( $response ) ) {
334 - return array( 'success' => false, 'message' => $response->get_error_message() );
335 - }
336 -
337 - $code = wp_remote_retrieve_response_code( $response );
338 - $body = json_decode( wp_remote_retrieve_body( $response ), true );
339 -
340 - if ( $code >= 200 && $code < 300 ) {
341 - return array(
342 - 'success' => true,
343 - 'values' => isset( $body['values'] ) ? $body['values'] : array(),
344 - 'message' => 'Values retrieved successfully.',
345 - );
346 - }
347 -
348 - $error = isset( $body['error']['message'] ) ? $body['error']['message'] : 'Unknown API error.';
349 - if ( $code === 400 && strpos( $error, 'Unable to parse range' ) !== false ) {
350 - $available_sheets = $this->get_spreadsheet_sheets( $spreadsheet_id, $sa_json );
351 - $sheet_list = ! empty( $available_sheets ) ? ' Available sheets: ' . implode( ', ', $available_sheets ) : ' (Could not retrieve list of available sheets).';
352 - $error .= ' Tip: Ensure the Sheet Name matches exactly (case-sensitive). ' . $sheet_list;
353 - }
354 - return array( 'success' => false, 'message' => "Google Sheets Get Values failed (HTTP {$code}): {$error}" );
355 - }
356 -
357 - /**
358 - * Extract fields from trigger data for automated mapping.
359 - *
360 - * @param array $trigger_data Data passed from the trigger.
361 - * @return array List of values.
362 - */
363 - private function extract_fields_from_trigger( $trigger_data ) {
364 - $values = array();
365 -
366 - // Case 1: Fields array (WPForms, Fluent Forms, etc.)
367 - if ( ! empty( $trigger_data['fields'] ) && is_array( $trigger_data['fields'] ) ) {
368 - foreach ( $trigger_data['fields'] as $field ) {
369 - // WPForms structure: fields[id][value] or fields[id] = value
370 - if ( is_array( $field ) ) {
371 - if ( isset( $field['value'] ) ) {
372 - $values[] = $field['value'];
373 - } else {
374 - // Flatten any other nested arrays or just skip
375 - $values[] = wp_json_encode( $field );
376 - }
377 - }
378 - // CF7 / Fluent Forms often just have key => value
379 - elseif ( is_scalar( $field ) ) {
380 - $values[] = $field;
381 - }
382 - }
383 - }
384 -
385 - // Fallback: If no fields, but we have some core data.
386 - if ( empty( $values ) ) {
387 - // Some triggers might pass data directly.
388 - $exclude_keys = array( 'form_id', 'form_name', 'entry_id', 'sub_id' );
389 - foreach ( $trigger_data as $key => $val ) {
390 - if ( ! in_array( $key, $exclude_keys ) && ( is_string( $val ) || is_numeric( $val ) ) ) {
391 - $values[] = $val;
392 - }
393 - }
394 - }
395 -
396 - return $values;
397 - }
398 -
399 - // ─────────────────────────────────────────────────────────────────────────
400 - // JWT / Auth Helpers
401 - // ─────────────────────────────────────────────────────────────────────────
402 -
403 - /**
404 - * Obtain an OAuth2 access token from a Service Account JSON string.
405 - *
406 - * Builds and signs a JWT, then exchanges it for an access token via
407 - * https://oauth2.googleapis.com/token — no external library needed.
408 - *
409 - * @param string $sa_json Raw service account JSON string.
410 - * @return string|\WP_Error Access token string or WP_Error on failure.
411 - */
412 - private function get_access_token( $sa_json ) {
413 - // Attempt to decode the service account JSON.
414 - $sa = json_decode( $sa_json, true );
415 -
416 - if ( JSON_ERROR_NONE !== json_last_error() || empty( $sa['private_key'] ) || empty( $sa['client_email'] ) ) {
417 - return new \WP_Error(
418 - 'invalid_sa_json',
419 - __( 'Google Sheets: Invalid service account JSON. Check the credentials field.', 'wpbot-automator' )
420 - );
421 - }
422 -
423 - $now = time();
424 - $header = $this->base64url_encode( wp_json_encode( array( 'alg' => 'RS256', 'typ' => 'JWT' ) ) );
425 - $claim = $this->base64url_encode(
426 - wp_json_encode(
427 - array(
428 - 'iss' => $sa['client_email'],
429 - 'scope' => 'https://www.googleapis.com/auth/spreadsheets',
430 - 'aud' => 'https://oauth2.googleapis.com/token',
431 - 'iat' => $now,
432 - 'exp' => $now + 3600,
433 - )
434 - )
435 - );
436 -
437 - $to_sign = $header . '.' . $claim;
438 - $key = openssl_pkey_get_private( $sa['private_key'] );
439 -
440 - if ( false === $key ) {
441 - return new \WP_Error(
442 - 'invalid_private_key',
443 - __( 'Google Sheets: Could not parse private key from service account JSON.', 'wpbot-automator' )
444 - );
445 - }
446 -
447 - $signature = '';
448 - if ( ! openssl_sign( $to_sign, $signature, $key, 'SHA256' ) ) {
449 - return new \WP_Error(
450 - 'sign_failed',
451 - __( 'Google Sheets: Failed to sign JWT. Ensure OpenSSL is enabled on your server.', 'wpbot-automator' )
452 - );
453 - }
454 -
455 - $jwt = $to_sign . '.' . $this->base64url_encode( $signature );
456 -
457 - $response = wp_remote_post(
458 - 'https://oauth2.googleapis.com/token',
459 - array(
460 - 'body' => array(
461 - 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
462 - 'assertion' => $jwt,
463 - ),
464 - 'timeout' => 20,
465 - )
466 - );
467 -
468 - if ( is_wp_error( $response ) ) {
469 - return $response;
470 - }
471 -
472 - $token_data = json_decode( wp_remote_retrieve_body( $response ), true );
473 -
474 - if ( empty( $token_data['access_token'] ) ) {
475 - $err = isset( $token_data['error_description'] ) ? $token_data['error_description'] : 'Unknown token error.';
476 - return new \WP_Error( 'token_error', "Google Sheets auth failed: {$err}" );
477 - }
478 -
479 - return $token_data['access_token'];
480 - }
481 -
482 -
483 - /**
484 - * Ensure a sheet name or range is properly quoted for the Google Sheets API.
485 - *
486 - * @param string $range The range or sheet name.
487 - * @return string
488 - */
489 - private function quote_sheet_name( $range ) {
490 - if ( empty( $range ) ) {
491 - return $range;
492 - }
493 -
494 - // If it contains a '!', we need to quote the part before it.
495 - if ( strpos( $range, '!' ) !== false ) {
496 - $parts = explode( '!', $range, 2 );
497 - $sheet = $parts[0];
498 - $cells = $parts[1];
499 - // Only quote if not already quoted AND if it needs quoting (contains non-alphanumeric).
500 - if ( strpos( $sheet, "'" ) !== 0 && preg_match( '/[^A-Za-z0-9_]/', $sheet ) ) {
501 - $sheet = "'" . str_replace( "'", "''", $sheet ) . "'";
502 - }
503 - return $sheet . '!' . $cells;
504 - }
505 -
506 - // If no '!', assume it's a sheet name.
507 - // Only quote if not already quoted AND if it needs quoting.
508 - if ( strpos( $range, "'" ) !== 0 && preg_match( '/[^A-Za-z0-9_]/', $range ) ) {
509 - return "'" . str_replace( "'", "''", $range ) . "'";
510 - }
511 -
512 - return $range;
513 - }
514 -
515 - /**
516 - * Base64URL encode (RFC 4648 §5, no padding).
517 - *
518 - * @param string $data Data to encode.
519 - * @return string
520 - */
521 - private function base64url_encode( $data ) {
522 - return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
523 - }
524 -
525 - /**
526 - * Handle a Google API response uniformly.
527 - *
528 - * @param array|\WP_Error $response wp_remote_* response.
529 - * @param string $context Human-readable action name for error messages.
530 - * @return array { success: bool, message: string }
531 - */
532 - private function handle_api_response( $response, $context, $spreadsheet_id = '', $sa_json = '' ) {
533 - if ( is_wp_error( $response ) ) {
534 - return array( 'success' => false, 'message' => $response->get_error_message() );
535 - }
536 -
537 - $code = wp_remote_retrieve_response_code( $response );
538 - $body = json_decode( wp_remote_retrieve_body( $response ), true );
539 -
540 - if ( $code >= 200 && $code < 300 ) {
541 - return array( 'success' => true, 'message' => "Google Sheets {$context} completed successfully." );
542 - }
543 -
544 - $error = isset( $body['error']['message'] ) ? $body['error']['message'] : 'Unknown API error.';
545 -
546 - // Advanced diagnostics for range parsing errors.
547 - if ( $code === 400 && strpos( $error, 'Unable to parse range' ) !== false ) {
548 - $available_sheets = $this->get_spreadsheet_sheets( $spreadsheet_id, $sa_json );
549 - $sheet_list = ! empty( $available_sheets ) ? ' Available sheets in this spreadsheet: ' . implode( ', ', $available_sheets ) : ' (Could not retrieve list of available sheets).';
550 - $error .= ' Tip: Ensure the Sheet Name matches exactly (case-sensitive). ' . $sheet_list;
551 - }
552 -
553 - return array( 'success' => false, 'message' => "Google Sheets {$context} failed (HTTP {$code}): {$error}" );
554 - }
555 -
556 - /**
557 - * Get a list of all sheet titles in a spreadsheet.
558 - *
559 - * @param string $spreadsheet_id Spreadsheet ID.
560 - * @param string $sa_json Service Account JSON.
561 - * @return array List of sheet titles.
562 - */
563 - private function get_spreadsheet_sheets( $spreadsheet_id, $sa_json ) {
564 - if ( empty( $spreadsheet_id ) || empty( $sa_json ) ) {
565 - return array();
566 - }
567 -
568 - $token = $this->get_access_token( $sa_json );
569 - if ( is_wp_error( $token ) ) {
570 - return array();
571 - }
572 -
573 - $url = "https://sheets.googleapis.com/v4/spreadsheets/{$spreadsheet_id}?fields=sheets.properties.title";
574 - $response = wp_remote_get(
575 - $url,
576 - array(
577 - 'headers' => array(
578 - 'Authorization' => 'Bearer ' . $token,
579 - ),
580 - 'timeout' => 20,
581 - )
582 - );
583 -
584 - if ( is_wp_error( $response ) ) {
585 - return array();
586 - }
587 -
588 - $body = json_decode( wp_remote_retrieve_body( $response ), true );
589 - $titles = array();
590 - if ( ! empty( $body['sheets'] ) ) {
591 - foreach ( $body['sheets'] as $sheet ) {
592 - if ( isset( $sheet['properties']['title'] ) ) {
593 - $titles[] = $sheet['properties']['title'];
594 - }
595 - }
596 - }
597 - return $titles;
598 - }
599 -
600 - /**
601 - * Create a new sheet in a spreadsheet.
602 - *
603 - * @param string $spreadsheet_id Spreadsheet ID.
604 - * @param string $sheet_name Sheet Name to create.
605 - * @param string $sa_json Service Account JSON.
606 - * @return bool|\WP_Error
607 - */
608 - private function create_sheet( $spreadsheet_id, $sheet_name, $sa_json ) {
609 - $token = $this->get_access_token( $sa_json );
610 - if ( is_wp_error( $token ) ) {
611 - return $token;
612 - }
613 -
614 - $url = "https://sheets.googleapis.com/v4/spreadsheets/{$spreadsheet_id}:batchUpdate";
615 - $response = wp_remote_post(
616 - $url,
617 - array(
618 - 'headers' => array(
619 - 'Authorization' => 'Bearer ' . $token,
620 - 'Content-Type' => 'application/json',
621 - ),
622 - 'body' => wp_json_encode(
623 - array(
624 - 'requests' => array(
625 - array(
626 - 'addSheet' => array(
627 - 'properties' => array(
628 - 'title' => $sheet_name,
629 - ),
630 - ),
631 - ),
632 - ),
633 - )
634 - ),
635 - 'timeout' => 20,
636 - )
637 - );
638 -
639 - if ( is_wp_error( $response ) ) {
640 - return $response;
641 - }
642 -
643 - $code = wp_remote_retrieve_response_code( $response );
644 - if ( $code < 200 || $code >= 300 ) {
645 - $body = json_decode( wp_remote_retrieve_body( $response ), true );
646 - $error = isset( $body['error']['message'] ) ? $body['error']['message'] : 'Unknown error creating sheet.';
647 - return new \WP_Error( 'gs_create_sheet_failed', "HTTP {$code}: {$error}" );
648 - }
649 -
650 - return true;
651 - }
652 -}
1 +<?php
2 +/**
3 + * Google Sheets Actions
4 + *
5 + * @package WPbot_Automator
6 + */
7 +
8 +namespace WPbot_Automator\Actions;
9 +
10 +if ( ! defined( 'ABSPATH' ) ) {
11 + exit;
12 +}
13 +
14 +/**
15 + * Class Google_Sheets_Actions
16 + *
17 + * Integrates with the Google Sheets API v4 using a Service Account (JWT auth).
18 + * No OAuth redirect flow required — users paste their Service Account JSON key directly.
19 + */
20 +class Google_Sheets_Actions extends Action {
21 +
22 + /**
23 + * Constructor
24 + */
25 + public function __construct() {
26 + $this->id = 'google_sheets';
27 + $this->group = __( 'Google Sheets', 'wpbot-automator' );
28 + }
29 +
30 + /**
31 + * Execute the action
32 + *
33 + * @param array $action_data Configuration for this action from the workflow.
34 + * @param array $trigger_data Data passed from the trigger.
35 + * @return bool|array
36 + */
37 + public function execute( $action_data, $trigger_data ) {
38 + $action_id = isset( $action_data['actionId'] ) ? $action_data['actionId'] : '';
39 +
40 + if ( empty( $action_id ) ) {
41 + return false;
42 + }
43 +
44 + switch ( $action_id ) {
45 + case 'gs_add_row':
46 + case 'add_row':
47 + return $this->add_row( $action_data, $trigger_data );
48 + case 'gs_update_row':
49 + case 'update_row':
50 + return $this->update_row( $action_data, $trigger_data );
51 + case 'gs_clear_range':
52 + case 'clear_range':
53 + return $this->clear_range( $action_data, $trigger_data );
54 + case 'gs_get_values':
55 + case 'get_values':
56 + return $this->get_values( $action_data, $trigger_data );
57 + default:
58 + return false;
59 + }
60 + }
61 +
62 + // ─────────────────────────────────────────────────────────────────────────
63 + // Action Handlers
64 + // ─────────────────────────────────────────────────────────────────────────
65 +
66 + /**
67 + * Append a new row to a Google Sheet.
68 + */
69 + private function add_row( $action_data, $trigger_data ) {
70 +
71 + $cfg = isset( $action_data['config'] ) ? $action_data['config'] : array();
72 + $spreadsheet_id = trim( $this->parse_tokens( $cfg['spreadsheet_id'] ?? $cfg['sheet_id'] ?? '', $trigger_data ) );
73 + $sheet_name_raw = trim( $this->parse_tokens( $cfg['sheet_name'] ?? $cfg['worksheet'] ?? '', $trigger_data ) );
74 + $sheet_name = ! empty( $sheet_name_raw ) ? $sheet_name_raw : 'Sheet1';
75 + $row_data_temp = $cfg['row_data'] ?? '';
76 + $sa_json = trim( $cfg['service_account_json'] ?? '' );
77 +
78 + if ( empty( $sa_json ) ) {
79 + $sa_json = get_option( 'wpbot_automator_gs_sa_json', '' );
80 + }
81 +
82 + if ( empty( $spreadsheet_id ) || empty( $sa_json ) ) {
83 + return array(
84 + 'success' => false,
85 + 'message' => 'Spreadsheet ID and Service Account JSON are required.',
86 + );
87 + }
88 +
89 + $token = $this->get_access_token( $sa_json );
90 + if ( is_wp_error( $token ) ) {
91 + return array( 'success' => false, 'message' => $token->get_error_message() );
92 + }
93 +
94 + // ✅ Validate Sheet Exists First
95 + $sheet_check = wp_remote_get(
96 + "https://sheets.googleapis.com/v4/spreadsheets/{$spreadsheet_id}?fields=sheets.properties.title",
97 + array(
98 + 'headers' => array(
99 + 'Authorization' => 'Bearer ' . $token,
100 + ),
101 + 'timeout' => 20,
102 + )
103 + );
104 +
105 + if ( is_wp_error( $sheet_check ) ) {
106 + return array( 'success' => false, 'message' => $sheet_check->get_error_message() );
107 + }
108 +
109 + $sheet_body = json_decode( wp_remote_retrieve_body( $sheet_check ), true );
110 +
111 + if ( empty( $sheet_body['sheets'] ) ) {
112 + return array( 'success' => false, 'message' => 'Unable to retrieve sheets from spreadsheet.' );
113 + }
114 +
115 + $available_sheets = array_map(
116 + function ( $s ) {
117 + return $s['properties']['title'];
118 + },
119 + $sheet_body['sheets']
120 + );
121 +
122 + if ( ! in_array( $sheet_name, $available_sheets, true ) ) {
123 + $create_missing = $cfg['create_missing_sheet'] ?? true; // Default to true if not set
124 + if ( $create_missing ) {
125 + $created = $this->create_sheet( $spreadsheet_id, $sheet_name, $sa_json );
126 + if ( is_wp_error( $created ) ) {
127 + return array( 'success' => false, 'message' => 'Sheet "' . esc_html( $sheet_name ) . '" not found and auto-creation failed: ' . $created->get_error_message() );
128 + }
129 + } else {
130 + return array(
131 + 'success' => false,
132 + 'message' => 'Sheet "' . esc_html( $sheet_name ) . '" not found. Available sheets: ' . implode( ', ', $available_sheets ),
133 + );
134 + }
135 + }
136 +
137 + // ✅ Parse row data by splitting the template first to prevent inline commas/newlines from breaking columns
138 + if ( ! empty( $row_data_temp ) ) {
139 + $values_template = is_array( $row_data_temp ) ? $row_data_temp : preg_split( '/[\n,]+/', $row_data_temp );
140 + $values_template = array_map( 'trim', $values_template );
141 + $values = array();
142 + foreach ( $values_template as $tpl ) {
143 + if ( $tpl !== '' ) {
144 + $values[] = $this->parse_tokens( $tpl, $trigger_data );
145 + }
146 + }
147 + } else {
148 + $values = $this->extract_fields_from_trigger( $trigger_data );
149 + }
150 +
151 + if ( empty( $values ) ) {
152 + return array(
153 + 'success' => false,
154 + 'message' => 'No row data provided.',
155 + );
156 + }
157 +
158 + // ✅ Properly quote sheet name if it contains spaces or special characters
159 + $sheet_name = $this->quote_sheet_name( $sheet_name );
160 +
161 + // ✅ Use safe append range
162 + $range = $sheet_name . '!A:Z';
163 +
164 + $url = "https://sheets.googleapis.com/v4/spreadsheets/{$spreadsheet_id}/values/" . rawurlencode( $range ) . ":append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS";
165 +
166 + $response = wp_remote_post(
167 + $url,
168 + array(
169 + 'headers' => array(
170 + 'Authorization' => 'Bearer ' . $token,
171 + 'Content-Type' => 'application/json',
172 + ),
173 + 'body' => wp_json_encode(
174 + array(
175 + 'values' => array( $values ),
176 + )
177 + ),
178 + 'timeout' => 20,
179 + )
180 + );
181 +
182 + $result = $this->handle_api_response( $response, 'Add Row', $spreadsheet_id, $sa_json );
183 +
184 + if ( $result['success'] && ! empty( $cfg['service_account_json'] ) ) {
185 + update_option( 'wpbot_automator_gs_sa_json', $cfg['service_account_json'] );
186 + }
187 +
188 + return $result;
189 + }
190 +
191 + /**
192 + * Update a specific row in a Google Sheet.
193 + */
194 + private function update_row( $action_data, $trigger_data ) {
195 + $cfg = isset( $action_data['config'] ) ? $action_data['config'] : array();
196 + $spreadsheet_id = trim( $this->parse_tokens( $cfg['spreadsheet_id'] ?? $cfg['sheet_id'] ?? '', $trigger_data ) );
197 + $sheet_name_raw = trim( $this->parse_tokens( $cfg['sheet_name'] ?? $cfg['worksheet'] ?? '', $trigger_data ) );
198 + $sheet_name = ! empty( $sheet_name_raw ) ? $sheet_name_raw : 'Sheet1';
199 + $row_number = absint( $this->parse_tokens( $cfg['row_number'] ?? '', $trigger_data ) );
200 + $row_data_temp = $cfg['row_data'] ?? '';
201 + $sa_json = trim( $cfg['service_account_json'] ?? '' );
202 +
203 + if ( empty( $sa_json ) ) {
204 + $sa_json = get_option( 'wpbot_automator_gs_sa_json', '' );
205 + }
206 +
207 + if ( empty( $spreadsheet_id ) || empty( $sa_json ) || empty( $row_data_temp ) || empty( $row_number ) ) {
208 + return array(
209 + 'success' => false,
210 + 'message' => 'Google Sheets Update Row: spreadsheet_id, service_account_json, row_data, and row_number are required.',
211 + );
212 + }
213 +
214 + $token = $this->get_access_token( $sa_json );
215 + if ( is_wp_error( $token ) ) {
216 + return array( 'success' => false, 'message' => $token->get_error_message() );
217 + }
218 +
219 + $values_template = is_array( $row_data_temp ) ? $row_data_temp : preg_split( '/[\n,]+/', $row_data_temp );
220 + $values_template = array_map( 'trim', $values_template );
221 + $values = array();
222 + foreach ( $values_template as $tpl ) {
223 + if ( $tpl !== '' ) {
224 + $values[] = $this->parse_tokens( $tpl, $trigger_data );
225 + }
226 + }
227 +
228 + $range = rawurlencode( $this->quote_sheet_name( "{$sheet_name}!A{$row_number}" ) );
229 + $url = "https://sheets.googleapis.com/v4/spreadsheets/{$spreadsheet_id}/values/{$range}?valueInputOption=USER_ENTERED";
230 +
231 + $response = wp_remote_request(
232 + $url,
233 + array(
234 + 'method' => 'PUT',
235 + 'headers' => array(
236 + 'Authorization' => 'Bearer ' . $token,
237 + 'Content-Type' => 'application/json',
238 + ),
239 + 'body' => wp_json_encode(
240 + array(
241 + 'range' => $this->quote_sheet_name( "{$sheet_name}!A{$row_number}" ),
242 + 'values' => array( $values ),
243 + )
244 + ),
245 + 'timeout' => 20,
246 + )
247 + );
248 +
249 + return $this->handle_api_response( $response, 'Update Row', $spreadsheet_id, $sa_json );
250 + }
251 +
252 + /**
253 + * Clear a range in a Google Sheet.
254 + */
255 + private function clear_range( $action_data, $trigger_data ) {
256 + $cfg = isset( $action_data['config'] ) ? $action_data['config'] : array();
257 + $spreadsheet_id = trim( $this->parse_tokens( isset( $cfg['spreadsheet_id'] ) ? $cfg['spreadsheet_id'] : '', $trigger_data ) );
258 + $range = trim( $this->parse_tokens( isset( $cfg['range'] ) ? $cfg['range'] : '', $trigger_data ) );
259 + $sa_json = trim( isset( $cfg['service_account_json'] ) ? $cfg['service_account_json'] : '' );
260 +
261 + if ( empty( $sa_json ) ) {
262 + $sa_json = get_option( 'wpbot_automator_gs_sa_json', '' );
263 + }
264 +
265 + if ( empty( $spreadsheet_id ) || empty( $sa_json ) || empty( $range ) ) {
266 + return array(
267 + 'success' => false,
268 + 'message' => 'Google Sheets Clear Range: spreadsheet_id, service_account_json, and range are required.',
269 + );
270 + }
271 +
272 + $token = $this->get_access_token( $sa_json );
273 + if ( is_wp_error( $token ) ) {
274 + return array( 'success' => false, 'message' => $token->get_error_message() );
275 + }
276 +
277 + $encoded_range = rawurlencode( $this->quote_sheet_name( $range ) );
278 + $url = "https://sheets.googleapis.com/v4/spreadsheets/{$spreadsheet_id}/values/{$encoded_range}:clear";
279 +
280 + $response = wp_remote_post(
281 + $url,
282 + array(
283 + 'headers' => array(
284 + 'Authorization' => 'Bearer ' . $token,
285 + 'Content-Type' => 'application/json',
286 + ),
287 + 'body' => '{}',
288 + 'timeout' => 20,
289 + )
290 + );
291 +
292 + return $this->handle_api_response( $response, 'Clear Range', $spreadsheet_id, $sa_json );
293 + }
294 +
295 + /**
296 + * Get values from a range in a Google Sheet.
297 + */
298 + private function get_values( $action_data, $trigger_data ) {
299 + $cfg = isset( $action_data['config'] ) ? $action_data['config'] : array();
300 + $spreadsheet_id = trim( $this->parse_tokens( isset( $cfg['spreadsheet_id'] ) ? $cfg['spreadsheet_id'] : '', $trigger_data ) );
301 + $range = trim( $this->parse_tokens( isset( $cfg['range'] ) ? $cfg['range'] : '', $trigger_data ) );
302 + $sa_json = trim( isset( $cfg['service_account_json'] ) ? $cfg['service_account_json'] : '' );
303 +
304 + if ( empty( $spreadsheet_id ) || empty( $sa_json ) || empty( $range ) ) {
305 + $hint = '';
306 + if ( ! empty( $trigger_data['fields'] ) || ( isset( $trigger_data['form_id'] ) ) ) {
307 + $hint = ' Hint: If you want to SAVE form data to a sheet, please use the "Add Row (Save Form Data)" action instead.';
308 + }
309 + return array(
310 + 'success' => false,
311 + 'message' => 'Google Sheets Get Values: spreadsheet_id, service_account_json, and range are required.' . $hint,
312 + );
313 + }
314 +
315 + $token = $this->get_access_token( $sa_json );
316 + if ( is_wp_error( $token ) ) {
317 + return array( 'success' => false, 'message' => $token->get_error_message() );
318 + }
319 +
320 + $encoded_range = rawurlencode( $range );
321 + $url = "https://sheets.googleapis.com/v4/spreadsheets/{$spreadsheet_id}/values/{$encoded_range}";
322 +
323 + $response = wp_remote_get(
324 + $url,
325 + array(
326 + 'headers' => array(
327 + 'Authorization' => 'Bearer ' . $token,
328 + ),
329 + 'timeout' => 20,
330 + )
331 + );
332 +
333 + if ( is_wp_error( $response ) ) {
334 + return array( 'success' => false, 'message' => $response->get_error_message() );
335 + }
336 +
337 + $code = wp_remote_retrieve_response_code( $response );
338 + $body = json_decode( wp_remote_retrieve_body( $response ), true );
339 +
340 + if ( $code >= 200 && $code < 300 ) {
341 + return array(
342 + 'success' => true,
343 + 'values' => isset( $body['values'] ) ? $body['values'] : array(),
344 + 'message' => 'Values retrieved successfully.',
345 + );
346 + }
347 +
348 + $error = isset( $body['error']['message'] ) ? $body['error']['message'] : 'Unknown API error.';
349 + if ( $code === 400 && strpos( $error, 'Unable to parse range' ) !== false ) {
350 + $available_sheets = $this->get_spreadsheet_sheets( $spreadsheet_id, $sa_json );
351 + $sheet_list = ! empty( $available_sheets ) ? ' Available sheets: ' . implode( ', ', $available_sheets ) : ' (Could not retrieve list of available sheets).';
352 + $error .= ' Tip: Ensure the Sheet Name matches exactly (case-sensitive). ' . $sheet_list;
353 + }
354 + return array( 'success' => false, 'message' => "Google Sheets Get Values failed (HTTP {$code}): {$error}" );
355 + }
356 +
357 + /**
358 + * Extract fields from trigger data for automated mapping.
359 + *
360 + * @param array $trigger_data Data passed from the trigger.
361 + * @return array List of values.
362 + */
363 + private function extract_fields_from_trigger( $trigger_data ) {
364 + $values = array();
365 +
366 + // Case 1: Fields array (WPForms, Fluent Forms, etc.)
367 + if ( ! empty( $trigger_data['fields'] ) && is_array( $trigger_data['fields'] ) ) {
368 + foreach ( $trigger_data['fields'] as $field ) {
369 + // WPForms structure: fields[id][value] or fields[id] = value
370 + if ( is_array( $field ) ) {
371 + if ( isset( $field['value'] ) ) {
372 + $values[] = $field['value'];
373 + } else {
374 + // Flatten any other nested arrays or just skip
375 + $values[] = wp_json_encode( $field );
376 + }
377 + }
378 + // CF7 / Fluent Forms often just have key => value
379 + elseif ( is_scalar( $field ) ) {
380 + $values[] = $field;
381 + }
382 + }
383 + }
384 +
385 + // Fallback: If no fields, but we have some core data.
386 + if ( empty( $values ) ) {
387 + // Some triggers might pass data directly.
388 + $exclude_keys = array( 'form_id', 'form_name', 'entry_id', 'sub_id' );
389 + foreach ( $trigger_data as $key => $val ) {
390 + if ( ! in_array( $key, $exclude_keys ) && ( is_string( $val ) || is_numeric( $val ) ) ) {
391 + $values[] = $val;
392 + }
393 + }
394 + }
395 +
396 + return $values;
397 + }
398 +
399 + // ─────────────────────────────────────────────────────────────────────────
400 + // JWT / Auth Helpers
401 + // ─────────────────────────────────────────────────────────────────────────
402 +
403 + /**
404 + * Obtain an OAuth2 access token from a Service Account JSON string.
405 + *
406 + * Builds and signs a JWT, then exchanges it for an access token via
407 + * https://oauth2.googleapis.com/token — no external library needed.
408 + *
409 + * @param string $sa_json Raw service account JSON string.
410 + * @return string|\WP_Error Access token string or WP_Error on failure.
411 + */
412 + private function get_access_token( $sa_json ) {
413 + // Attempt to decode the service account JSON.
414 + $sa = json_decode( $sa_json, true );
415 +
416 + if ( JSON_ERROR_NONE !== json_last_error() || empty( $sa['private_key'] ) || empty( $sa['client_email'] ) ) {
417 + return new \WP_Error(
418 + 'invalid_sa_json',
419 + __( 'Google Sheets: Invalid service account JSON. Check the credentials field.', 'wpbot-automator' )
420 + );
421 + }
422 +
423 + $now = time();
424 + $header = $this->base64url_encode( wp_json_encode( array( 'alg' => 'RS256', 'typ' => 'JWT' ) ) );
425 + $claim = $this->base64url_encode(
426 + wp_json_encode(
427 + array(
428 + 'iss' => $sa['client_email'],
429 + 'scope' => 'https://www.googleapis.com/auth/spreadsheets',
430 + 'aud' => 'https://oauth2.googleapis.com/token',
431 + 'iat' => $now,
432 + 'exp' => $now + 3600,
433 + )
434 + )
435 + );
436 +
437 + $to_sign = $header . '.' . $claim;
438 + $key = openssl_pkey_get_private( $sa['private_key'] );
439 +
440 + if ( false === $key ) {
441 + return new \WP_Error(
442 + 'invalid_private_key',
443 + __( 'Google Sheets: Could not parse private key from service account JSON.', 'wpbot-automator' )
444 + );
445 + }
446 +
447 + $signature = '';
448 + if ( ! openssl_sign( $to_sign, $signature, $key, 'SHA256' ) ) {
449 + return new \WP_Error(
450 + 'sign_failed',
451 + __( 'Google Sheets: Failed to sign JWT. Ensure OpenSSL is enabled on your server.', 'wpbot-automator' )
452 + );
453 + }
454 +
455 + $jwt = $to_sign . '.' . $this->base64url_encode( $signature );
456 +
457 + $response = wp_remote_post(
458 + 'https://oauth2.googleapis.com/token',
459 + array(
460 + 'body' => array(
461 + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
462 + 'assertion' => $jwt,
463 + ),
464 + 'timeout' => 20,
465 + )
466 + );
467 +
468 + if ( is_wp_error( $response ) ) {
469 + return $response;
470 + }
471 +
472 + $token_data = json_decode( wp_remote_retrieve_body( $response ), true );
473 +
474 + if ( empty( $token_data['access_token'] ) ) {
475 + $err = isset( $token_data['error_description'] ) ? $token_data['error_description'] : 'Unknown token error.';
476 + return new \WP_Error( 'token_error', "Google Sheets auth failed: {$err}" );
477 + }
478 +
479 + return $token_data['access_token'];
480 + }
481 +
482 +
483 + /**
484 + * Ensure a sheet name or range is properly quoted for the Google Sheets API.
485 + *
486 + * @param string $range The range or sheet name.
487 + * @return string
488 + */
489 + private function quote_sheet_name( $range ) {
490 + if ( empty( $range ) ) {
491 + return $range;
492 + }
493 +
494 + // If it contains a '!', we need to quote the part before it.
495 + if ( strpos( $range, '!' ) !== false ) {
496 + $parts = explode( '!', $range, 2 );
497 + $sheet = $parts[0];
498 + $cells = $parts[1];
499 + // Only quote if not already quoted AND if it needs quoting (contains non-alphanumeric).
500 + if ( strpos( $sheet, "'" ) !== 0 && preg_match( '/[^A-Za-z0-9_]/', $sheet ) ) {
501 + $sheet = "'" . str_replace( "'", "''", $sheet ) . "'";
502 + }
503 + return $sheet . '!' . $cells;
504 + }
505 +
506 + // If no '!', assume it's a sheet name.
507 + // Only quote if not already quoted AND if it needs quoting.
508 + if ( strpos( $range, "'" ) !== 0 && preg_match( '/[^A-Za-z0-9_]/', $range ) ) {
509 + return "'" . str_replace( "'", "''", $range ) . "'";
510 + }
511 +
512 + return $range;
513 + }
514 +
515 + /**
516 + * Base64URL encode (RFC 4648 §5, no padding).
517 + *
518 + * @param string $data Data to encode.
519 + * @return string
520 + */
521 + private function base64url_encode( $data ) {
522 + return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
523 + }
524 +
525 + /**
526 + * Handle a Google API response uniformly.
527 + *
528 + * @param array|\WP_Error $response wp_remote_* response.
529 + * @param string $context Human-readable action name for error messages.
530 + * @return array { success: bool, message: string }
531 + */
532 + private function handle_api_response( $response, $context, $spreadsheet_id = '', $sa_json = '' ) {
533 + if ( is_wp_error( $response ) ) {
534 + return array( 'success' => false, 'message' => $response->get_error_message() );
535 + }
536 +
537 + $code = wp_remote_retrieve_response_code( $response );
538 + $body = json_decode( wp_remote_retrieve_body( $response ), true );
539 +
540 + if ( $code >= 200 && $code < 300 ) {
541 + return array( 'success' => true, 'message' => "Google Sheets {$context} completed successfully." );
542 + }
543 +
544 + $error = isset( $body['error']['message'] ) ? $body['error']['message'] : 'Unknown API error.';
545 +
546 + // Advanced diagnostics for range parsing errors.
547 + if ( $code === 400 && strpos( $error, 'Unable to parse range' ) !== false ) {
548 + $available_sheets = $this->get_spreadsheet_sheets( $spreadsheet_id, $sa_json );
549 + $sheet_list = ! empty( $available_sheets ) ? ' Available sheets in this spreadsheet: ' . implode( ', ', $available_sheets ) : ' (Could not retrieve list of available sheets).';
550 + $error .= ' Tip: Ensure the Sheet Name matches exactly (case-sensitive). ' . $sheet_list;
551 + }
552 +
553 + return array( 'success' => false, 'message' => "Google Sheets {$context} failed (HTTP {$code}): {$error}" );
554 + }
555 +
556 + /**
557 + * Get a list of all sheet titles in a spreadsheet.
558 + *
559 + * @param string $spreadsheet_id Spreadsheet ID.
560 + * @param string $sa_json Service Account JSON.
561 + * @return array List of sheet titles.
562 + */
563 + private function get_spreadsheet_sheets( $spreadsheet_id, $sa_json ) {
564 + if ( empty( $spreadsheet_id ) || empty( $sa_json ) ) {
565 + return array();
566 + }
567 +
568 + $token = $this->get_access_token( $sa_json );
569 + if ( is_wp_error( $token ) ) {
570 + return array();
571 + }
572 +
573 + $url = "https://sheets.googleapis.com/v4/spreadsheets/{$spreadsheet_id}?fields=sheets.properties.title";
574 + $response = wp_remote_get(
575 + $url,
576 + array(
577 + 'headers' => array(
578 + 'Authorization' => 'Bearer ' . $token,
579 + ),
580 + 'timeout' => 20,
581 + )
582 + );
583 +
584 + if ( is_wp_error( $response ) ) {
585 + return array();
586 + }
587 +
588 + $body = json_decode( wp_remote_retrieve_body( $response ), true );
589 + $titles = array();
590 + if ( ! empty( $body['sheets'] ) ) {
591 + foreach ( $body['sheets'] as $sheet ) {
592 + if ( isset( $sheet['properties']['title'] ) ) {
593 + $titles[] = $sheet['properties']['title'];
594 + }
595 + }
596 + }
597 + return $titles;
598 + }
599 +
600 + /**
601 + * Create a new sheet in a spreadsheet.
602 + *
603 + * @param string $spreadsheet_id Spreadsheet ID.
604 + * @param string $sheet_name Sheet Name to create.
605 + * @param string $sa_json Service Account JSON.
606 + * @return bool|\WP_Error
607 + */
608 + private function create_sheet( $spreadsheet_id, $sheet_name, $sa_json ) {
609 + $token = $this->get_access_token( $sa_json );
610 + if ( is_wp_error( $token ) ) {
611 + return $token;
612 + }
613 +
614 + $url = "https://sheets.googleapis.com/v4/spreadsheets/{$spreadsheet_id}:batchUpdate";
615 + $response = wp_remote_post(
616 + $url,
617 + array(
618 + 'headers' => array(
619 + 'Authorization' => 'Bearer ' . $token,
620 + 'Content-Type' => 'application/json',
621 + ),
622 + 'body' => wp_json_encode(
623 + array(
624 + 'requests' => array(
625 + array(
626 + 'addSheet' => array(
627 + 'properties' => array(
628 + 'title' => $sheet_name,
629 + ),
630 + ),
631 + ),
632 + ),
633 + )
634 + ),
635 + 'timeout' => 20,
636 + )
637 + );
638 +
639 + if ( is_wp_error( $response ) ) {
640 + return $response;
641 + }
642 +
643 + $code = wp_remote_retrieve_response_code( $response );
644 + if ( $code < 200 || $code >= 300 ) {
645 + $body = json_decode( wp_remote_retrieve_body( $response ), true );
646 + $error = isset( $body['error']['message'] ) ? $body['error']['message'] : 'Unknown error creating sheet.';
647 + return new \WP_Error( 'gs_create_sheet_failed', "HTTP {$code}: {$error}" );
648 + }
649 +
650 + return true;
651 + }
652 +}