client = new Google_Client(); // Service account JSON $this->client->setAuthConfig( ROOTPATH . 'nhance-ee8d1-e3c5269b1ec7.json' ); // IMPORTANT for service account $this->client->useApplicationDefaultCredentials(); // Required scopes $this->client->addScope([ Google_Service_Drive::DRIVE, Google_Service_Sheets::SPREADSHEETS, ]); // Init services $this->sheets = new Google_Service_Sheets($this->client); $this->drive = new Google_Service_Drive($this->client); } /* ===================== READ ===================== */ public function read(string $spreadsheetId, string $range = 'RFQ Page') { $response = $this->sheets ->spreadsheets_values ->get($spreadsheetId, $range); return $response->getValues() ?? []; } /* ===================== WRITE ===================== */ public function write(string $spreadsheetId, array $values, string $range = 'Sheet1') { $body = new Google_Service_Sheets_ValueRange([ 'values' => $values, ]); $this->sheets ->spreadsheets_values ->update( $spreadsheetId, $range, $body, ['valueInputOption' => 'RAW'] ); return true; } /* ===================== DOWNLOAD ===================== */ public function downloadExcel(string $spreadsheetId) { $response = $this->drive->files->export( $spreadsheetId, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ['alt' => 'media'] ); return $response->getBody()->getContents(); } /* ================= LIST FOLDER SHEETS ===================== */ /** * Lists files inside a Google Drive folder and returns JSON with * filename and sheet id (Drive file id / spreadsheet id). */ public function listFolderSheetIdsAsJson(string $folderId = ''): string { if ($folderId === '') { $folderId = '19uySI-PSFQfFpZvMSnBtirXbwfCTXFBL'; } $results = []; $pageToken = null; $folderIdEsc = str_replace("'", "\\'", $folderId); $query = "'{$folderIdEsc}' in parents and trashed = false"; $listParams = [ 'q' => $query, 'fields' => 'nextPageToken, files(id,name,mimeType)', 'pageSize' => 1000, 'supportsAllDrives' => true, 'includeItemsFromAllDrives' => true, ]; do { if ($pageToken) { $listParams['pageToken'] = $pageToken; } else { unset($listParams['pageToken']); } $response = $this->drive->files->listFiles($listParams); foreach (($response->getFiles() ?? []) as $file) { // "sheetId" here is the Drive file id (spreadsheetId for Google Sheets) $results[] = [ 'name' => (string) $file->getName(), 'sheetId' => (string) $file->getId(), ]; } $pageToken = $response->getNextPageToken(); } while (! empty($pageToken)); return json_encode($results, JSON_UNESCAPED_SLASHES); } /* ================= COPY TEMPLATE ================= */ public function copyTemplate(string $templateId, string $name, string $folderId): string { $file = $this->drive->files->copy( $templateId, new \Google_Service_Drive_DriveFile([ 'name' => $name, 'parents' => [$folderId], ]), [ 'supportsAllDrives' => true, 'fields' => 'id, name, parents', ] ); return $file->id; } /** * Find/replace text across all sheets in the spreadsheet (batchUpdate). * * @param array $replacements placeholder => replacement (empty string allowed) */ public function findAndReplaceAllSheets(string $spreadsheetId, array $replacements): void { if ($replacements === []) { return; } $requests = []; foreach ($replacements as $find => $replacement) { if ($find === '') { continue; } $requests[] = [ 'findReplace' => [ 'find' => (string) $find, 'replacement' => (string) $replacement, 'allSheets' => true, 'matchCase' => false, 'matchEntireCell' => false, 'includeFormulas' => true, ], ]; } if ($requests === []) { return; } $this->sheets->spreadsheets->batchUpdate( $spreadsheetId, new Google_Service_Sheets_BatchUpdateSpreadsheetRequest([ 'requests' => $requests, ]) ); } /** * Quote a sheet title for A1 notation ranges. */ private function quoteSheetTitleForRange(string $title): string { return "'" . str_replace("'", "''", $title) . "'"; } /** * 1-based column index to Excel column letters (A, B, …, Z, AA, …). */ private function columnNumberToLetters(int $n): string { $s = ''; while ($n > 0) { $m = ($n - 1) % 26; $s = chr(65 + $m) . $s; $n = intdiv($n - 1, 26); } return $s; } /** * Sheet title from an A1 range like 'RFQ Page'!A1:ZZ3000 or Sheet1!A1. */ private function parseSheetTitleFromBatchRange(string $rangeStr): string { $parts = explode('!', $rangeStr, 2); $sheet = $parts[0] ?? ''; if ($sheet === '') { return ''; } if (str_starts_with($sheet, "'")) { return str_replace("''", "'", trim($sheet, "'")); } return $sheet; } /** * Locate a placeholder in any sheet and write a 2D table starting at that cell. * First row of $tableValues is treated as headers. * * @param list> $tableValues */ public function writeTableAtPlaceholder(string $spreadsheetId, string $placeholder, array $tableValues): bool { if ($placeholder === '' || $tableValues === []) { return false; } $meta = $this->sheets->spreadsheets->get( $spreadsheetId, ['fields' => 'sheets(properties(title))'] ); $titles = []; foreach ($meta->getSheets() ?? [] as $sheet) { $props = $sheet->getProperties(); if ($props !== null) { $titles[] = (string) $props->getTitle(); } } if ($titles === []) { return false; } $ranges = []; // Use row-based range so API returns row 1 == index 0 (leading empty rows are not dropped). foreach ($titles as $title) { $ranges[] = $this->quoteSheetTitleForRange($title) . '!1:3000'; } $batch = $this->sheets->spreadsheets_values->batchGet($spreadsheetId, [ 'ranges' => $ranges, 'majorDimension' => 'ROWS', ]); $valueRanges = $batch->getValueRanges() ?? []; foreach ($valueRanges as $vr) { $title = $this->parseSheetTitleFromBatchRange((string) ($vr->getRange() ?? '')); if ($title === '') { continue; } $values = $vr->getValues() ?? []; foreach ($values as $r => $row) { if (! is_array($row)) { continue; } foreach ($row as $c => $cell) { if (! is_string($cell) && ! is_numeric($cell)) { continue; } $text = (string) $cell; if (! str_contains($text, $placeholder)) { continue; } $startCol = $this->columnNumberToLetters((int) $c + 1); $startRow = (int) $r + 1; $q = $this->quoteSheetTitleForRange($title); $range = "{$q}!{$startCol}{$startRow}"; $body = new Google_Service_Sheets_ValueRange([ 'values' => $tableValues, ]); $this->sheets->spreadsheets_values->update( $spreadsheetId, $range, $body, ['valueInputOption' => 'USER_ENTERED'] ); return true; } } } return false; } /* ================= PERMISSIONS ================= */ public function applyPermissions(string $fileId, array $permissions) { foreach ($permissions['editors'] as $email) { $this->createPermission($fileId, $email, 'writer'); } foreach ($permissions['viewers'] as $email) { $this->createPermission($fileId, $email, 'reader'); } } private function createPermission(string $fileId, string $email, string $role) { $type = str_starts_with($email, 'group:') ? 'group' : 'user'; $email = str_replace('group:', '', $email); $this->drive->permissions->create( $fileId, new \Google_Service_Drive_Permission([ 'type' => $type, 'role' => $role, 'emailAddress' => $email, ]), ['sendNotificationEmail' => false, 'supportsAllDrives' => true] ); } /* ================= PROTECTIONS ================= */ public function applyProtectionsold(string $spreadsheetId, array $ranges) { $spreadsheet = $this->sheets->spreadsheets->get($spreadsheetId); $sheetId = $spreadsheet->getSheets()[0]->getProperties()->getSheetId(); $requests = []; foreach ($ranges as $range) { [$sheetName, $a1] = explode('!', $range); $requests[] = [ 'addProtectedRange' => [ 'protectedRange' => [ 'range' => [ 'sheetId' => $sheetId, ], 'warningOnly' => false, ], ], ]; } $this->sheets->spreadsheets->batchUpdate( $spreadsheetId, new Google_Service_Sheets_BatchUpdateSpreadsheetRequest([ 'requests' => $requests, ]) ); } public function applyProtections(string $spreadsheetId, array $protections) { // Fetch spreadsheet metadata $spreadsheet = $this->sheets->spreadsheets->get( $spreadsheetId, ['fields' => 'sheets(properties(sheetId,title,gridProperties))'] ); // Map sheet names $sheetMap = []; foreach ($spreadsheet->getSheets() as $sheet) { $props = $sheet->getProperties(); $sheetMap[$props->getTitle()] = [ 'sheetId' => $props->getSheetId(), 'rowCount' => $props->getGridProperties()->getRowCount(), 'colCount' => $props->getGridProperties()->getColumnCount(), ]; } $requests = []; foreach ($protections as $protection) { $rangeStr = $protection['range']; if (! str_contains($rangeStr, '!')) { throw new \Exception("Invalid range format: {$rangeStr}"); } [$sheetName, $a1] = explode('!', $rangeStr, 2); if (! isset($sheetMap[$sheetName])) { throw new \Exception("Sheet not found: {$sheetName}"); } $sheetMeta = $sheetMap[$sheetName]; $gridRange = $this->convertA1ToGridRange( $a1, $sheetMeta['sheetId'], $sheetMeta['rowCount'], $sheetMeta['colCount'] ); $protectedRange = [ 'range' => $gridRange, 'description' => 'RFQ Protected Area', 'warningOnly' => false, 'editors' => [ 'users' => $protection['users'] ?? [], 'groups' => $protection['groups'] ?? [], ], ]; $requests[] = [ 'addProtectedRange' => [ 'protectedRange' => $protectedRange, ], ]; } if (! empty($requests)) { $batch = new \Google_Service_Sheets_BatchUpdateSpreadsheetRequest([ 'requests' => $requests, ]); $this->sheets->spreadsheets->batchUpdate($spreadsheetId, $batch); } return true; } /* ================= FILE NAME ================= */ public function getFileName(string $fileId): string { $file = $this->drive->files->get($fileId, [ 'supportsAllDrives' => true, 'fields' => 'name', ]); return $file->getName() ?? ''; } /* ================= URL ================= */ public function sheetUrl(string $sheetId): string { return "https://docs.google.com/spreadsheets/d/{$sheetId}/edit"; } private function convertA1ToGridRange($a1, $sheetId, $maxRows, $maxCols) { if (preg_match('/^([A-Z]+)(\d+)(?::([A-Z]+)(\d+))?$/i', $a1, $m)) { $startCol = $this->colToIndex($m[1]); $startRow = intval($m[2]) - 1; if (! empty($m[3])) { $endCol = $this->colToIndex($m[3]) + 1; $endRow = intval($m[4]); } else { $endCol = $startCol + 1; $endRow = $startRow + 1; } return [ 'sheetId' => $sheetId, 'startRowIndex' => $startRow, 'endRowIndex' => $endRow, 'startColumnIndex' => $startCol, 'endColumnIndex' => $endCol, ]; } throw new \Exception("Unsupported A1 format: {$a1}"); } private function colToIndex($letters) { $letters = strtoupper($letters); $index = 0; for ($i = 0; $i < strlen($letters); $i++) { $index = $index * 26 + (ord($letters[$i]) - 64); } return $index - 1; } }