lock = $lock; $this->worksheetManager = $worksheetManager; } /** * Ensures headers are created on the worksheet if it is empty. * * @param string $accessToken * @param string $spreadsheetId * @param string $worksheetName * @param array $headers * @return void * @throws Exception */ public function ensureHeaders( string $accessToken, string $spreadsheetId, string $worksheetName, array $headers, string $headerBgColor = '', string $headerFgColor = '', bool $headerSticky = false ): void { $lockKey = md5($spreadsheetId . '_' . $worksheetName); $acquired = false; // Try to acquire the lock (retry for up to 5 seconds). for ($i = 0; $i < 10; $i++) { if ($this->lock->acquire($lockKey, 20)) { $acquired = true; break; } usleep(500000); // Wait 500ms } try { $range = $this->worksheetManager->getRangeString($worksheetName, 'A1:ZZ1'); $values = $this->readRow($accessToken, $spreadsheetId, $range); if (empty($values) || empty($values[0])) { $this->writeRow($accessToken, $spreadsheetId, $range, $headers); $this->styleHeaders( $accessToken, $spreadsheetId, $worksheetName, count($headers), $headerBgColor, $headerFgColor, $headerSticky ); } } finally { if ($acquired) { $this->lock->release($lockKey); } } } /** * Read the first row. * * @throws Exception */ private function readRow(string $accessToken, string $spreadsheetId, string $range): array { $url = sprintf( 'https://sheets.googleapis.com/v4/spreadsheets/%s/values/%s', rawurlencode($spreadsheetId), rawurlencode($range) ); $response = wp_remote_get($url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken, 'Accept' => 'application/json', ], 'timeout' => 15, ]); if (is_wp_error($response)) { throw new Exception(esc_html($response->get_error_message())); } $code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); if ($code !== 200) { $this->handleApiError($code, $body); } $data = json_decode($body, true); return $data['values'] ?? []; } /** * Write headers to the first row. * * @throws Exception */ private function writeRow(string $accessToken, string $spreadsheetId, string $range, array $values): void { $url = sprintf( 'https://sheets.googleapis.com/v4/spreadsheets/%s/values/%s?valueInputOption=RAW', rawurlencode($spreadsheetId), rawurlencode($range) ); $response = wp_remote_post($url, [ 'method' => 'PUT', 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken, 'Content-Type' => 'application/json', ], 'body' => json_encode([ 'values' => [$values], ]), 'timeout' => 15, ]); if (is_wp_error($response)) { throw new Exception(esc_html($response->get_error_message())); } $code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); if ($code !== 200) { $this->handleApiError($code, $body); } } /** * Map HTTP errors to custom Google exceptions. * * @throws Exception */ private function handleApiError(int $code, string $bodyContent): void { $body = json_decode($bodyContent, true); $message = $body['error']['message'] ?? __('Google Sheets API error occurred.', '1-click-form-sync'); switch ($code) { case 429: throw new GoogleRateLimitException(esc_html($message)); case 403: throw new PermissionDeniedException(esc_html($message)); case 404: throw new SpreadsheetNotFoundException(esc_html($message)); case 400: if (strpos($message, 'Unable to parse range') !== false || strpos($message, 'not found') !== false) { throw new WorksheetNotFoundException(esc_html($message)); } throw new Exception(esc_html($message)); default: throw new Exception(esc_html($message)); } } /** * Style header row and apply freeze (sticky) property. * * @throws Exception */ private function styleHeaders( string $accessToken, string $spreadsheetId, string $worksheetName, int $columnCount, string $bgColor = '', string $fgColor = '', bool $sticky = false ): void { $sheetId = $this->getSheetIdByName($accessToken, $spreadsheetId, $worksheetName); if ($sheetId === null) { return; } $requests = []; $hasBg = !empty($bgColor) && preg_match('/^#?([a-f0-9]{6})$/i', $bgColor); $hasFg = !empty($fgColor) && preg_match('/^#?([a-f0-9]{6})$/i', $fgColor); if ($hasBg || $hasFg) { $userEnteredFormat = [ 'textFormat' => [ 'bold' => true, ], ]; if ($hasBg) { $bgRgb = $this->hexToRgb($bgColor); $userEnteredFormat['backgroundColor'] = [ 'red' => $bgRgb['red'], 'green' => $bgRgb['green'], 'blue' => $bgRgb['blue'], ]; } if ($hasFg) { $fgRgb = $this->hexToRgb($fgColor); $userEnteredFormat['textFormat']['foregroundColor'] = [ 'red' => $fgRgb['red'], 'green' => $fgRgb['green'], 'blue' => $fgRgb['blue'], ]; } $requests[] = [ 'repeatCell' => [ 'range' => [ 'sheetId' => $sheetId, 'startRowIndex' => 0, 'endRowIndex' => 1, 'startColumnIndex' => 0, 'endColumnIndex' => $columnCount, ], 'cell' => [ 'userEnteredFormat' => $userEnteredFormat, ], 'fields' => 'userEnteredFormat(backgroundColor,textFormat(bold,foregroundColor))', ], ]; } else { $requests[] = [ 'repeatCell' => [ 'range' => [ 'sheetId' => $sheetId, 'startRowIndex' => 0, 'endRowIndex' => 1, 'startColumnIndex' => 0, 'endColumnIndex' => $columnCount, ], 'cell' => [ 'userEnteredFormat' => [ 'textFormat' => [ 'bold' => true, ], ], ], 'fields' => 'userEnteredFormat(textFormat(bold))', ], ]; } if ($sticky) { $requests[] = [ 'updateSheetProperties' => [ 'properties' => [ 'sheetId' => $sheetId, 'gridProperties' => [ 'frozenRowCount' => 1, ], ], 'fields' => 'gridProperties.frozenRowCount', ], ]; } if (empty($requests)) { return; } $url = sprintf( 'https://sheets.googleapis.com/v4/spreadsheets/%s:batchUpdate', rawurlencode($spreadsheetId) ); $response = wp_remote_post($url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken, 'Content-Type' => 'application/json', ], 'body' => json_encode([ 'requests' => $requests, ]), 'timeout' => 15, ]); if (is_wp_error($response)) { throw new Exception(esc_html($response->get_error_message())); } $code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); if ($code !== 200) { $this->handleApiError($code, $body); } } /** * Get sheet ID by worksheet name. */ private function getSheetIdByName(string $accessToken, string $spreadsheetId, string $worksheetName): ?int { try { $url = sprintf( 'https://sheets.googleapis.com/v4/spreadsheets/%s?fields=sheets(properties(sheetId,title))', rawurlencode($spreadsheetId) ); $response = wp_remote_get($url, [ 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken, 'Accept' => 'application/json', ], 'timeout' => 15, ]); if (is_wp_error($response)) { return null; } $code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); if ($code !== 200) { return null; } $data = json_decode($body, true); $sheets = $data['sheets'] ?? []; foreach ($sheets as $sheet) { $properties = $sheet['properties'] ?? []; $title = $properties['title'] ?? ''; if (strcasecmp($title, $worksheetName) === 0) { return (int)($properties['sheetId'] ?? 0); } } } catch (Exception $e) { return null; } return null; } /** * Convert hex color code to RGB normalized float values. */ private function hexToRgb(string $hex): array { $hex = ltrim($hex, '#'); if (strlen($hex) === 3) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } $r = hexdec(substr($hex, 0, 2)) / 255.0; $g = hexdec(substr($hex, 2, 2)) / 255.0; $b = hexdec(substr($hex, 4, 2)) / 255.0; return [ 'red' => $r, 'green' => $g, 'blue' => $b, ]; } }